hotcell-server 0.0.0 → 0.2.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/MIT-LICENSE +20 -0
- data/README.md +3 -0
- data/exe/hotcell +11 -0
- data/exe/hotcell-health +38 -0
- data/lib/hot_cell/configuration.rb +124 -0
- data/lib/hot_cell/control.rb +62 -0
- data/lib/hot_cell/counters.rb +50 -0
- data/lib/hot_cell/limits.rb +142 -0
- data/lib/hot_cell/log.rb +120 -0
- data/lib/hot_cell/operation.rb +205 -0
- data/lib/hot_cell/registry.rb +51 -0
- data/lib/hot_cell/server/errors.rb +16 -0
- data/lib/hot_cell/server/version.rb +7 -0
- data/lib/hot_cell/server.rb +45 -0
- data/lib/hot_cell/slot.rb +171 -0
- data/lib/hot_cell/supervisor.rb +884 -0
- data/lib/hot_cell/test_cell.rb +156 -0
- data/lib/hot_cell/test_operations.rb +465 -0
- data/lib/hot_cell/timing.rb +47 -0
- data/lib/hot_cell/worker.rb +324 -0
- data/lib/hotcell-server.rb +6 -0
- metadata +55 -9
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HotCell
|
|
4
|
+
# Every operation takes inputs, outputs, and the payload — and the payload arrives as keyword
|
|
5
|
+
# arguments. There is no argument schema and no generated code, but an operation may declare the
|
|
6
|
+
# specific keywords it wants (`format:, operations: {}`), and Ruby validates them on arrival; one that
|
|
7
|
+
# wants the payload as a plain Hash declares `**payload` as its third argument, and one that takes no
|
|
8
|
+
# options declares neither. A mismatched call raises inside perform and reports `failed`, and the
|
|
9
|
+
# blast radius of that is one worker with no network.
|
|
10
|
+
#
|
|
11
|
+
# This is the one place application code deliberately runs inside a cell. The invariant is not "no
|
|
12
|
+
# application code": it is that the code running there has no credentials, no database, no network, and
|
|
13
|
+
# no application configuration.
|
|
14
|
+
class Operation
|
|
15
|
+
READ_BYTES = 16 * 1024
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
include Declarations
|
|
19
|
+
|
|
20
|
+
def inherited(subclass)
|
|
21
|
+
super
|
|
22
|
+
Registry.register subclass
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Declares a class that exists to be inherited from, not to be dispatched to.
|
|
26
|
+
#
|
|
27
|
+
# Every subclass registers, because that is what makes an operation reachable by writing it. An
|
|
28
|
+
# intermediate that gathers shared setup registers too, and a cell then advertises it in `describe` and
|
|
29
|
+
# accepts it on the wire — where it reaches a `perform` that raises NotImplementedError and answers
|
|
30
|
+
# `failed`, as though the caller's document were the problem.
|
|
31
|
+
#
|
|
32
|
+
# Deliberately not inherited: a subclass of an abstract operation is concrete unless it says otherwise,
|
|
33
|
+
# and a class-level instance variable is not visible to a subclass, so that falls out for free.
|
|
34
|
+
def abstract_operation
|
|
35
|
+
@abstract_operation = true
|
|
36
|
+
Registry.reload!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def abstract_operation?
|
|
40
|
+
@abstract_operation == true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Writes the routing name, defaulting to the underscored class name with namespaces as dots.
|
|
44
|
+
def operation(name = nil)
|
|
45
|
+
return operation_name if name.nil?
|
|
46
|
+
|
|
47
|
+
@operation_name = name.to_s
|
|
48
|
+
Registry.reload!
|
|
49
|
+
@operation_name
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def operation_name
|
|
53
|
+
@operation_name || derived_operation_name
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A class-level declaration that accumulates. Naming one limit changes one and keeps the rest — of this
|
|
57
|
+
# class's own declaration, or of the nearest ancestor's when this class has none yet. That is what
|
|
58
|
+
# lets a subclass narrow a single number, and what lets an operator give a shipped operation a
|
|
59
|
+
# different budget from an operations file, after the operation loads, without editing the gem. The
|
|
60
|
+
# cell's own limits still clamp whatever is declared. docs/DEPLOYMENT.md, "Changing a shipped
|
|
61
|
+
# operation's limits".
|
|
62
|
+
def limits(**values)
|
|
63
|
+
return inherited_value(:@limits) || Limits.new if values.empty?
|
|
64
|
+
|
|
65
|
+
@limits = limits.merge(**values)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Runs in the supervisor, once, at boot. It may require and it may configure, and it must never
|
|
69
|
+
# evaluate an image.
|
|
70
|
+
#
|
|
71
|
+
# That rule is what the whole process model rests on, and breaking it is a silent hang rather than a
|
|
72
|
+
# crash. Measured: a parent that has only required image_processing/vips and set concurrency has
|
|
73
|
+
# three threads and forks children that work. One additional 1x1 evaluation takes it to five, and
|
|
74
|
+
# from then on every forked worker blocks forever in futex_do_wait, because the GLib thread pool
|
|
75
|
+
# does not survive fork and the child waits on a pool with no threads. Not the first worker — every
|
|
76
|
+
# worker. A later change that pre-warms the pool to save the fork cost is exactly what this forbids.
|
|
77
|
+
#
|
|
78
|
+
# Note that a hotcell forks per request, continuously, so "before the fork" and "at boot" are not
|
|
79
|
+
# the same moment the way they are in Puma. This runs once.
|
|
80
|
+
def before_fork(&block)
|
|
81
|
+
return collected(:@before_fork) if block.nil?
|
|
82
|
+
|
|
83
|
+
(@before_fork ||= []) << block
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Runs in the worker after the fork and before it serves anything. This is where an operation sizes
|
|
87
|
+
# its library, and the framework deliberately does not do it for anyone: Vips.concurrency_set 4 in a
|
|
88
|
+
# cell given two CPUs running twenty workers is forty threads on two cores, and getting that right
|
|
89
|
+
# is the operation author's problem against the cell's own numbers.
|
|
90
|
+
#
|
|
91
|
+
# Configuration belongs here rather than in before_fork because it is global and singular. Two
|
|
92
|
+
# operations configuring the same library in the supervisor would silently disagree, with the last one
|
|
93
|
+
# registered winning.
|
|
94
|
+
#
|
|
95
|
+
# A worker is not one operation, which is what makes this the right place rather than a safe one. Above
|
|
96
|
+
# `max_requests_per_worker: 1` it can serve A, then B, then A, and these hooks re-run whenever the operation
|
|
97
|
+
# changes — so what the library is set up for always matches what is about to run. Write them to be
|
|
98
|
+
# re-entrant: they are setters against shared state, not one-time initialization.
|
|
99
|
+
def before_worker_boot(&block)
|
|
100
|
+
return collected(:@before_worker_boot) if block.nil?
|
|
101
|
+
|
|
102
|
+
(@before_worker_boot ||= []) << block
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Library exceptions that mean the input could not be decoded, rather than that the operation broke.
|
|
106
|
+
def unreadable(*classes)
|
|
107
|
+
return collected(:@unreadable) + [ UnreadableInput ] if classes.empty?
|
|
108
|
+
|
|
109
|
+
(@unreadable ||= []).concat classes
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
def derived_operation_name
|
|
114
|
+
Naming.default_operation_name self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Superclass first, so a base class's hooks run before the ones that specialize it.
|
|
118
|
+
def collected(variable)
|
|
119
|
+
ancestors.grep(Class).reverse.flat_map { |ancestor| ancestor.instance_variable_get(variable) || [] }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A fresh instance per request, so that nothing an operation puts in an instance variable survives
|
|
124
|
+
# into the next request a reused worker serves.
|
|
125
|
+
def perform(inputs, outputs, **)
|
|
126
|
+
raise NotImplementedError, "#{self.class} must implement perform(inputs, outputs, <keywords>)"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
ToolResult = Struct.new(:status, :out, :err) do
|
|
130
|
+
def ok?
|
|
131
|
+
status.success?
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Runs a tool with `unsetenv_others` and a fully written environment, never a filtered copy of this
|
|
136
|
+
# worker's own. This is invariant 9, and it is the only point in the whole design where we control what a
|
|
137
|
+
# tool's /proc/<pid>/environ shows.
|
|
138
|
+
#
|
|
139
|
+
# Filtering would not work. The worker is forked, so its own /proc/self/environ is the exec-time
|
|
140
|
+
# environment of the process it was forked from, and ENV.delete changes nothing that a sibling worker can
|
|
141
|
+
# read. An exec'd child is different: it gets a fresh environ, and this is where that gets written.
|
|
142
|
+
#
|
|
143
|
+
# Bounded output, because a tool's stdout is attacker-influenced — and worth remembering when an
|
|
144
|
+
# operation parses it, because doing so brings the bytes a tool was isolating back into this worker.
|
|
145
|
+
#
|
|
146
|
+
# `capture` bounds what is kept AND what is read, which capture3 could not do. It accumulates both
|
|
147
|
+
# streams in full and hands them over at exit, so slicing afterwards bounded the Strings this method
|
|
148
|
+
# returns and nothing else: an input that makes a tool print gigabytes of diagnostics had already
|
|
149
|
+
# cost gigabytes of this worker's address space, and took RLIMIT_DATA with it — arriving as a `memory`
|
|
150
|
+
# verdict, which is permanent, for a document whose only crime was being noisy.
|
|
151
|
+
# `pass` hands the tool a set of the worker's own descriptors — an input to read, an output to write —
|
|
152
|
+
# at their existing fd numbers, so the tool reaches them as `/dev/fd/N` (Descriptor#fd_path) and no
|
|
153
|
+
# byte is copied onto scratch to give it a filename. A fd handed to a child this way loses its
|
|
154
|
+
# close-on-exec, which is exactly the inheritance wanted, and only for these; the worker's other
|
|
155
|
+
# descriptors are untouched. Passing an fd at its own number cannot collide with the stdio pipes popen3
|
|
156
|
+
# installs on 0, 1 and 2, because a received descriptor is never one of those.
|
|
157
|
+
def run_tool(*command, env: {}, capture: 64 * 1024, pass: [])
|
|
158
|
+
require "open3"
|
|
159
|
+
|
|
160
|
+
inherit = pass.to_h { |io| [ io.fileno, io.fileno ] }
|
|
161
|
+
|
|
162
|
+
Open3.popen3(tool_environment(env), *command, unsetenv_others: true, **inherit) do |stdin, out, err, thread|
|
|
163
|
+
stdin.close
|
|
164
|
+
captured = drain(out, err, capture)
|
|
165
|
+
|
|
166
|
+
ToolResult.new thread.value, captured[out], captured[err]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
# Reads both streams until they close, keeping only the first `limit` bytes of each and discarding the
|
|
172
|
+
# rest as it arrives. Both have to be read, not just the one being kept: a tool blocks writing to
|
|
173
|
+
# a pipe nobody drains, and a tool blocked on stderr never exits, which turns a noisy document
|
|
174
|
+
# into a deadline kill.
|
|
175
|
+
def drain(*streams, limit)
|
|
176
|
+
kept = streams.to_h { |stream| [ stream, +"".b ] }
|
|
177
|
+
open = streams.dup
|
|
178
|
+
|
|
179
|
+
until open.empty?
|
|
180
|
+
readable, = IO.select(open)
|
|
181
|
+
|
|
182
|
+
Array(readable).each do |stream|
|
|
183
|
+
chunk = stream.read_nonblock(READ_BYTES, exception: false)
|
|
184
|
+
next if chunk == :wait_readable
|
|
185
|
+
|
|
186
|
+
if chunk.nil?
|
|
187
|
+
open.delete stream
|
|
188
|
+
next
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
room = limit - kept[stream].bytesize
|
|
192
|
+
kept[stream] << chunk.byteslice(0, room) if room.positive?
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
kept
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def tool_environment(overrides)
|
|
200
|
+
{ "HOME" => ENV["HOME"], "PATH" => ENV["PATH"], "LANG" => "C.UTF-8", "LC_ALL" => "C.UTF-8" }
|
|
201
|
+
.merge(overrides.transform_keys(&:to_s))
|
|
202
|
+
.compact
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HotCell
|
|
4
|
+
# The `op` field on the wire is looked up here. A name that arrived on the wire is never used to derive a constant, so the
|
|
5
|
+
# index only ever holds classes this cell already loaded.
|
|
6
|
+
module Registry
|
|
7
|
+
class << self
|
|
8
|
+
def register(operation)
|
|
9
|
+
operations << operation unless operations.include?(operation)
|
|
10
|
+
reload!
|
|
11
|
+
operation
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def operations
|
|
15
|
+
@operations ||= []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def names
|
|
19
|
+
index.keys.sort
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def lookup(name)
|
|
23
|
+
index[name]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reload!
|
|
27
|
+
@index = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def clear
|
|
31
|
+
@operations = []
|
|
32
|
+
reload!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
def index
|
|
37
|
+
@index ||= operations.each_with_object({}) do |operation, names|
|
|
38
|
+
next if operation.abstract_operation?
|
|
39
|
+
|
|
40
|
+
name = operation.operation_name
|
|
41
|
+
|
|
42
|
+
if (claimed = names[name])
|
|
43
|
+
raise ConfigurationError, "#{operation} and #{claimed} both answer to #{name.inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
names[name] = operation
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HotCell
|
|
4
|
+
# What an operation raises to choose its own verdict. An operation may also declare the library
|
|
5
|
+
# exceptions that mean the same thing, with `unreadable Vips::Error`.
|
|
6
|
+
class OperationError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# The input could not be decoded. Terminal: the same bytes fail the same way on an idle cell. Common
|
|
9
|
+
# rather than exceptional — it covers truncated uploads, formats the build was not compiled with, and
|
|
10
|
+
# formats deliberately refused.
|
|
11
|
+
class UnreadableInput < OperationError; end
|
|
12
|
+
|
|
13
|
+
# Reported as `killed` with `cause: memory` rather than as an ordinary failure, because it is the
|
|
14
|
+
# decompression-bomb case and a caller must be able to act on it without parsing a message.
|
|
15
|
+
class MemoryExhausted < OperationError; end
|
|
16
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hot_cell/core"
|
|
4
|
+
|
|
5
|
+
require "hot_cell/server/version"
|
|
6
|
+
require "hot_cell/server/errors"
|
|
7
|
+
require "hot_cell/limits"
|
|
8
|
+
require "hot_cell/configuration"
|
|
9
|
+
require "hot_cell/registry"
|
|
10
|
+
require "hot_cell/operation"
|
|
11
|
+
require "hot_cell/slot"
|
|
12
|
+
require "hot_cell/log"
|
|
13
|
+
require "hot_cell/timing"
|
|
14
|
+
require "hot_cell/counters"
|
|
15
|
+
require "hot_cell/control"
|
|
16
|
+
require "hot_cell/worker"
|
|
17
|
+
require "hot_cell/supervisor"
|
|
18
|
+
|
|
19
|
+
module HotCell
|
|
20
|
+
class << self
|
|
21
|
+
def configuration
|
|
22
|
+
@configuration ||= Configuration.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A cell is configured once, and everything about scheduling lives here rather than on an operation.
|
|
26
|
+
#
|
|
27
|
+
# HotCell.limits concurrency: 4, queue_size: 8, deadline: 60, queue_wait: 10, max_requests_per_worker: 1,
|
|
28
|
+
# memory: 1536 * 1024**2, file_size: 48 * 1024**2
|
|
29
|
+
def limits(**options)
|
|
30
|
+
return configuration if options.empty?
|
|
31
|
+
|
|
32
|
+
@configuration = Configuration.new(**options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Boots a cell's code: the configuration file first, explicitly, then every operation file in sorted
|
|
36
|
+
# order. A derived image adds these by copying files in, never by changing this gem, and a cell with
|
|
37
|
+
# no config.rb takes every default.
|
|
38
|
+
def load!(config: ENV.fetch("HOTCELL_CONFIG", "/hotcell/config.rb"),
|
|
39
|
+
operations: ENV.fetch("HOTCELL_OPERATIONS", "/hotcell/operations"))
|
|
40
|
+
require config if File.exist?(config)
|
|
41
|
+
|
|
42
|
+
Dir.glob(File.join(operations, "**", "*.rb")).sort.each { |file| require file }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module HotCell
|
|
7
|
+
# Slots are a consequence of the concurrency limit rather than something to configure. At most
|
|
8
|
+
# `concurrency` workers run, so number them and hand each worker its number at fork. There is no
|
|
9
|
+
# leasing: a slot is always free when a worker starts, because the thing that bounds workers is the same
|
|
10
|
+
# thing that counts slots. A request never waits for a slot, it waits in the cell's queue.
|
|
11
|
+
#
|
|
12
|
+
# **A slot has one directory per request and it is that request's `$HOME`.** It is created when the
|
|
13
|
+
# request starts and removed when the request ends, so nothing a tool writes under `$HOME` reaches the
|
|
14
|
+
# next request on this slot.
|
|
15
|
+
#
|
|
16
|
+
# This directory used to survive, to give a tool with an expensive per-user profile a warm one. That is
|
|
17
|
+
# withdrawn. What a tool reads from `$HOME` is configuration, and for the toolchains a cell carries
|
|
18
|
+
# configuration is executable: ImageMagick runs the command lines in `delegates.xml` and applies the
|
|
19
|
+
# rights in `policy.xml`, both read from `$HOME/.config/ImageMagick`. A surviving home therefore let an
|
|
20
|
+
# input that achieved code execution reconfigure every later request on the slot, which is the bound
|
|
21
|
+
# `max_requests_per_worker: 1` is supposed to hold. adr/0003 records the reversal and adr/0002 the
|
|
22
|
+
# reasoning it supersedes.
|
|
23
|
+
#
|
|
24
|
+
# **The name is fresh for every request, and that is what makes the removal above a guarantee rather than
|
|
25
|
+
# an intention.** A stable name only holds while the deletion behind it works, and the tool that filled
|
|
26
|
+
# the directory runs as the user that owns it: `chmod 0500` on a directory it has written makes its own
|
|
27
|
+
# configuration unremovable, and the same mode on the slot directory makes it unrenameable too. Both
|
|
28
|
+
# cleanups answer false and both callers log — and a stable name then handed the next request the tree
|
|
29
|
+
# that had just refused to go. A name no earlier request has held is not a name an earlier request could
|
|
30
|
+
# have prepared, so what a failed cleanup now costs is disk rather than the isolation the cell is for.
|
|
31
|
+
#
|
|
32
|
+
# The mode of the slot directory is reasserted for the same reason. It is the one name here that is
|
|
33
|
+
# predictable, so it is the one an earlier request can lock, and a mode on a directory this uid owns is
|
|
34
|
+
# ours to put back.
|
|
35
|
+
#
|
|
36
|
+
# **This bounds what an earlier request left behind, and not what a live one is doing.** Workers share a
|
|
37
|
+
# uid and `0700` is the owner's own mode, so a concurrent sibling — or a `setsid` descendant of a finished
|
|
38
|
+
# request, which process groups do not contain — can still write into a home the moment it exists. The
|
|
39
|
+
# slot directory itself can be renamed aside and replaced, and `chmod` follows what it finds. Both are the
|
|
40
|
+
# residuals `docs/DESIGN.md` records under worker isolation, and neither is closed here.
|
|
41
|
+
#
|
|
42
|
+
# There is one directory per request and not two. A request's staged inputs and outputs are named inside
|
|
43
|
+
# `$HOME` rather than in a scratch directory of their own, because the two had the same lifetime and the
|
|
44
|
+
# same owner once the home stopped surviving. Staging used to create its directory on demand, which is
|
|
45
|
+
# what kept a descriptor-only operation from paying for one; `$HOME` has to exist for every request
|
|
46
|
+
# either way, so that laziness bought nothing and is gone with it.
|
|
47
|
+
#
|
|
48
|
+
# The filesystem behaviour belongs here rather than in the two processes that call it. The directory is
|
|
49
|
+
# removed from both — the worker before it answers, the supervisor at finish and at reap — so the guard
|
|
50
|
+
# and the swallowed SystemCallError are a rule that has to hold on both sides of a fork, and it had a
|
|
51
|
+
# copy on each.
|
|
52
|
+
Slot = Struct.new(:number, :directory, :home) do
|
|
53
|
+
def self.build(workspace, number)
|
|
54
|
+
new number, File.join(workspace, number.to_s)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A name no request has held before, so nothing an earlier one did to the tree it was given reaches this
|
|
58
|
+
# one. The suffix is random rather than a counter for the reason the discarded name's is: the previous
|
|
59
|
+
# request could write to this directory, and a predictable name is one it can pre-create.
|
|
60
|
+
def make_home
|
|
61
|
+
FileUtils.mkdir_p directory, mode: 0o700
|
|
62
|
+
FileUtils.chmod 0o700, directory
|
|
63
|
+
|
|
64
|
+
self.home = File.join(directory, "home-#{SecureRandom.hex(8)}")
|
|
65
|
+
Dir.mkdir home, 0o700
|
|
66
|
+
home
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# **Returns whether the directory is gone, and every caller logs when it is not.**
|
|
70
|
+
#
|
|
71
|
+
# A home that was already removed, or that another process removed between the check and the unlink, is
|
|
72
|
+
# the outcome this wants either way, so that answers true. Failing to remove one is a different fact and
|
|
73
|
+
# it used to arrive as the same swallowed nil. This is the call that deletes a request's staged input and
|
|
74
|
+
# output before the caller is told anything, so a failure leaves those bytes on the tmpfs for the life of
|
|
75
|
+
# the container — and a sibling worker can cause one, by creating entries under the tree while
|
|
76
|
+
# remove_entry walks it. Same uid, no defence, and it was silent.
|
|
77
|
+
#
|
|
78
|
+
# It still cannot raise. Worker#serve calls this from an ensure, where a raise would replace the caller's
|
|
79
|
+
# response with a crash, and the supervisor calls discard_home from finish and reap, where nothing above
|
|
80
|
+
# rescues anything and a raise stops the cell with every request it holds.
|
|
81
|
+
def remove_home
|
|
82
|
+
return true if home.nil?
|
|
83
|
+
return false unless remove_tree(home)
|
|
84
|
+
|
|
85
|
+
self.home = nil
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# **The supervisor renames rather than deletes, and that is a scheduling decision.**
|
|
90
|
+
#
|
|
91
|
+
# How long a recursive delete takes is chosen by the operation that filled the directory. Nothing bounds
|
|
92
|
+
# the number of entries — RLIMIT_FSIZE caps one file, not a million tiny ones — so an input that makes a
|
|
93
|
+
# tool write an enormous tree and then hang buys a deletion the supervisor performs synchronously,
|
|
94
|
+
# after the kill, inside the loop enforcing every other request's deadline.
|
|
95
|
+
#
|
|
96
|
+
# A rename within one filesystem is O(1) and takes the tree out of the way. A worker sweeps it later,
|
|
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.
|
|
99
|
+
#
|
|
100
|
+
# The destination carries a random suffix rather than a counter, because the tool that filled the
|
|
101
|
+
# directory runs as this user and can write to the slot's directory. A predictable name lets it
|
|
102
|
+
# pre-create a colliding entry, fail the rename, and send the supervisor into the recursive delete the
|
|
103
|
+
# rename exists to avoid. On any rename failure the tree is left where it is, for a later worker's own
|
|
104
|
+
# cleanup to remove off the hot path. The supervisor never deletes a tree inline, whatever goes wrong.
|
|
105
|
+
# It renames what is there rather than the name it is holding, because the supervisor is the caller that
|
|
106
|
+
# matters and it does not know the name. `home` is assigned in the worker after the fork, so the
|
|
107
|
+
# supervisor's copy of the slot is still nil when it discards a killed worker's tree. At most one worker
|
|
108
|
+
# holds a slot at a time, so everything matching `home-*` under it is that worker's and nobody else's.
|
|
109
|
+
def discard_home
|
|
110
|
+
FileUtils.chmod 0o700, directory if Dir.exist?(directory)
|
|
111
|
+
|
|
112
|
+
Dir.glob(File.join(directory, "home-*")).each do |path|
|
|
113
|
+
File.rename path, File.join(directory, "discarded-#{Process.pid}-#{SecureRandom.hex(8)}")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
self.home = nil
|
|
117
|
+
true
|
|
118
|
+
rescue SystemCallError
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Nothing here is created at boot, because nothing survives a request. This only clears what an earlier
|
|
123
|
+
# boot left behind — the whole slot directory, since the names inside it are an earlier boot's and not
|
|
124
|
+
# this one's to reconstruct.
|
|
125
|
+
#
|
|
126
|
+
# `Dir.exist?` is not the guard, because it follows symlinks and answers false for a dangling one. An
|
|
127
|
+
# entry a tool left in the slot's place is exactly what this has to remove, and it used to survive the
|
|
128
|
+
# sweep and raise from every later `make_home`.
|
|
129
|
+
def prepare
|
|
130
|
+
remove_tree directory
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Unlinks whatever discard_home renamed out of the way. Partial progress is fine: a sweep killed
|
|
134
|
+
# part-way leaves fewer entries for the next one, so this converges rather than repeating.
|
|
135
|
+
def sweep
|
|
136
|
+
Dir.glob(File.join(directory, "discarded-*")).map { |path| remove_tree(path) }.all?
|
|
137
|
+
rescue SystemCallError
|
|
138
|
+
# The glob itself can fail, because the slot directory is a name a tool can replace — a symlink loop
|
|
139
|
+
# in its place answers ELOOP here rather than for any one entry. This runs from the worker's ensure,
|
|
140
|
+
# where a raise would replace the caller's response with a crash.
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
# **A mode is the only thing a tool needs to make its own tree unremovable, and a mode on a tree this
|
|
146
|
+
# uid owns is ours to put back.** `chmod 0500` on a directory a conversion wrote is enough to fail the
|
|
147
|
+
# recursive delete underneath it, and the delete failing used to be the end of it: one tree per
|
|
148
|
+
# request stayed on the tmpfs until the container ended. The repair is not a permission the process
|
|
149
|
+
# gains, it is one it never lost.
|
|
150
|
+
#
|
|
151
|
+
# Repair only after a failure, never before, so the common request pays for a walk of its own tree
|
|
152
|
+
# once rather than twice. `force:` on the chmod because a partial repair that removes most of the tree
|
|
153
|
+
# is better than none, and the remove that follows is what reports the outcome either way.
|
|
154
|
+
def remove_tree(path)
|
|
155
|
+
return true unless File.exist?(path) || File.symlink?(path)
|
|
156
|
+
|
|
157
|
+
FileUtils.remove_entry path
|
|
158
|
+
true
|
|
159
|
+
rescue SystemCallError
|
|
160
|
+
repair_and_remove path
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def repair_and_remove(path)
|
|
164
|
+
FileUtils.chmod_R 0o700, path, force: true
|
|
165
|
+
FileUtils.remove_entry path
|
|
166
|
+
true
|
|
167
|
+
rescue SystemCallError
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|