hotcell-server 0.5.0 → 1.0.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/lib/hot_cell/control.rb +5 -1
- data/lib/hot_cell/health_operations.rb +42 -0
- data/lib/hot_cell/log.rb +1 -1
- data/lib/hot_cell/operation.rb +1 -1
- data/lib/hot_cell/server/version.rb +1 -1
- data/lib/hot_cell/slot.rb +1 -1
- data/lib/hot_cell/supervisor.rb +41 -8
- data/lib/hot_cell/test_cell.rb +3 -0
- data/lib/hot_cell/test_operations.rb +10 -0
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bbb296d96887a9a1bc312a14acef431caa03fc30d05a3f77bd6708a8a9d596a4
|
|
4
|
+
data.tar.gz: 6f3a72abba2e2e90ca8b805c15b5bff8feef52d1b1ef5c0a73832e190cc4f182
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3facfa26f280439cbf43dc21aff1e3a4cfb3ba3212402f1d1e5f396b2773e629409f926cf8fcf00debcd68fdb33d126acfe7f2d487afdff194180eb724c4029f
|
|
7
|
+
data.tar.gz: 52b1e35eb13e510c912ca7bb9cc706186e36380ee166f5f6d2bff0c36a22088d84d4bed69db0e81cd987eb493b450dd7f71bfeb0d5db63531e3d2aa9cb7602c8
|
data/lib/hot_cell/control.rb
CHANGED
|
@@ -40,8 +40,12 @@ module HotCell
|
|
|
40
40
|
|
|
41
41
|
# Static, and called once per registered cell at app boot. It is the cheapest way to catch a client
|
|
42
42
|
# whose own timeout is below what this cell may take, and it is what `bin/hotcell describe` reads.
|
|
43
|
+
#
|
|
44
|
+
# `v` is the wire format and changes only when the format does; `server_version` says which release of
|
|
45
|
+
# hotcell-server is answering.
|
|
43
46
|
def describe
|
|
44
|
-
{ v: PROTOCOL_VERSION, operations: Registry.names, groups: groups,
|
|
47
|
+
{ v: PROTOCOL_VERSION, server_version: Server::VERSION, operations: Registry.names, groups: groups,
|
|
48
|
+
**@configuration.to_h }
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
private
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hot_cell/server"
|
|
4
|
+
|
|
5
|
+
# The two round trips an application runs against its own cell for a health check. Shipped in the
|
|
6
|
+
# gem because every deployment was copying them from examples/, and copies drift.
|
|
7
|
+
#
|
|
8
|
+
# A cell serves them only if it requires this file.
|
|
9
|
+
module HotCell
|
|
10
|
+
module Health
|
|
11
|
+
# The message goes in through the caller's input descriptor and out through the caller's output
|
|
12
|
+
# descriptor, with no copy onto scratch, so one round trip proves descriptor passing end to end.
|
|
13
|
+
class Echo < HotCell::Operation
|
|
14
|
+
operation "health.echo"
|
|
15
|
+
|
|
16
|
+
def perform(inputs, outputs)
|
|
17
|
+
bytes = outputs.first.to_io.write(inputs.first.to_io.read)
|
|
18
|
+
|
|
19
|
+
{ bytes: bytes, staged: inputs.first.staged? }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# `fd_path` is a fresh open by name — `/dev/fd/N` on Linux, the file's own path on macOS — checked
|
|
24
|
+
# against this process's uid and the file's mode, so this succeeds only when the cell can open the
|
|
25
|
+
# caller's files by name — which echo never checks.
|
|
26
|
+
# It reads the input and writes the output, because a tool may need either permission.
|
|
27
|
+
class Reopen < HotCell::Operation
|
|
28
|
+
operation "health.reopen"
|
|
29
|
+
|
|
30
|
+
def perform(inputs, outputs)
|
|
31
|
+
source, = inputs
|
|
32
|
+
destination, = outputs
|
|
33
|
+
|
|
34
|
+
bytes = File.open(source.fd_path, "rb") do |input|
|
|
35
|
+
File.open(destination.fd_path, "wb") { |output| output.write(input.read) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
{ bytes: bytes, staged: source.staged? || destination.staged? }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/hot_cell/log.rb
CHANGED
|
@@ -12,7 +12,7 @@ module HotCell
|
|
|
12
12
|
# This is the channel for everything no response can carry: deadline kills, reaps that found a signal,
|
|
13
13
|
# boot checks, and queue high-water.
|
|
14
14
|
#
|
|
15
|
-
# Lines follow docs/
|
|
15
|
+
# Lines follow docs/observability.md: ECS field names, domain fields under the hotcell namespace. The fleet's
|
|
16
16
|
# collector routes on service.name, takes the record timestamp from @timestamp, and severity from
|
|
17
17
|
# log.level, so those three are load-bearing: renaming any of them silently drops or mislabels every
|
|
18
18
|
# cell log line in production.
|
data/lib/hot_cell/operation.rb
CHANGED
|
@@ -57,7 +57,7 @@ module HotCell
|
|
|
57
57
|
# class's own declaration, or of the nearest ancestor's when this class has none yet. That is what
|
|
58
58
|
# lets a subclass narrow a single number, and what lets an operator give a shipped operation a
|
|
59
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/
|
|
60
|
+
# cell's own limits still clamp whatever is declared. docs/operation-api.md, "Changing a shipped
|
|
61
61
|
# operation's limits".
|
|
62
62
|
def limits(**values)
|
|
63
63
|
return inherited_value(:@limits) || Limits.new if values.empty?
|
data/lib/hot_cell/slot.rb
CHANGED
|
@@ -37,7 +37,7 @@ module HotCell
|
|
|
37
37
|
# uid and `0700` is the owner's own mode, so a concurrent sibling — or a `setsid` descendant of a finished
|
|
38
38
|
# request, which process groups do not contain — can still write into a home the moment it exists. The
|
|
39
39
|
# slot directory itself can be renamed aside and replaced, and `chmod` follows what it finds. Both are the
|
|
40
|
-
# residuals `docs/
|
|
40
|
+
# residuals `docs/design/worker-isolation.md` records, and neither is closed here.
|
|
41
41
|
#
|
|
42
42
|
# There is one directory per request and not two. A request's staged inputs and outputs are named inside
|
|
43
43
|
# `$HOME` rather than in a scratch directory of their own, because the two had the same lifetime and the
|
data/lib/hot_cell/supervisor.rb
CHANGED
|
@@ -191,6 +191,7 @@ module HotCell
|
|
|
191
191
|
verify_scratches!
|
|
192
192
|
prepare_directories
|
|
193
193
|
preload
|
|
194
|
+
warm_up
|
|
194
195
|
@work = listen "work.sock"
|
|
195
196
|
@control = listen "control.sock"
|
|
196
197
|
@control_handler = Control.new(configuration: configuration, counters: counters)
|
|
@@ -202,6 +203,8 @@ module HotCell
|
|
|
202
203
|
end
|
|
203
204
|
|
|
204
205
|
def run
|
|
206
|
+
prefork
|
|
207
|
+
|
|
205
208
|
until stopped?
|
|
206
209
|
readable, = IO.select(sources, nil, nil, wait_for)
|
|
207
210
|
Array(readable).each { |source| handle source }
|
|
@@ -278,7 +281,7 @@ module HotCell
|
|
|
278
281
|
# `Codes` already relies on when it refuses to attribute a signal to the input a worker was holding.
|
|
279
282
|
# The premise is that handling TERM is not optional — it is how an orchestrator stops a cell without
|
|
280
283
|
# killing requests in flight — and that denial of service against a cell is out of scope per
|
|
281
|
-
# docs/
|
|
284
|
+
# docs/design/threat-model.md.
|
|
282
285
|
def trap_signals
|
|
283
286
|
@signals, @signal_writer = IO.pipe
|
|
284
287
|
trap("CHLD") { @signal_writer.write_nonblock "C", exception: false }
|
|
@@ -463,8 +466,20 @@ module HotCell
|
|
|
463
466
|
@children.each_value.find(&:available?) || spawn
|
|
464
467
|
end
|
|
465
468
|
|
|
469
|
+
# Forks a worker into each free slot, so that a request does not wait for `fork`. If a fork fails,
|
|
470
|
+
# `spawn` logs it and this stops. The next request forks its own worker.
|
|
471
|
+
def prefork
|
|
472
|
+
return if @stopping
|
|
473
|
+
|
|
474
|
+
while free_slot
|
|
475
|
+
break unless spawn
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
466
479
|
def spawn
|
|
467
|
-
number = free_slot
|
|
480
|
+
number = free_slot
|
|
481
|
+
return nil if number.nil?
|
|
482
|
+
|
|
468
483
|
slot = Slot.build(workspace, number)
|
|
469
484
|
supervisor_side, worker_side = UNIXSocket.pair(:STREAM)
|
|
470
485
|
stderr_reader, stderr_writer = IO.pipe
|
|
@@ -512,9 +527,9 @@ module HotCell
|
|
|
512
527
|
# capability and so survives `cap-drop ALL`, and neither the deadline kill nor the reap sweep reaches
|
|
513
528
|
# it afterwards. It then runs untimed until `pids-limit` or the cgroup ends it.
|
|
514
529
|
#
|
|
515
|
-
# It reaches further than that, so this is not the denial of service docs/
|
|
530
|
+
# It reaches further than that, so this is not the denial of service docs/design/threat-model.md puts out of
|
|
516
531
|
# scope. A stolen listener needs a live process to hold it, and this escape is the only way one
|
|
517
|
-
# outlives the reap — so it is what turns the socket theft
|
|
532
|
+
# outlives the reap — so it is what turns the socket theft in docs/design/worker-isolation.md from a dead
|
|
518
533
|
# socket path into a cell that intercepts every later request.
|
|
519
534
|
#
|
|
520
535
|
# The premise is that nothing in this process prevents it. A process group is the only bound the
|
|
@@ -531,7 +546,7 @@ module HotCell
|
|
|
531
546
|
# for it: a warning written from inside libvips is a `write(2)` in a C call Ruby cannot interrupt, so
|
|
532
547
|
# the conversion would wait on the supervisor's scheduling — nearest exactly when the host is under
|
|
533
548
|
# pressure and the supervisor is scheduled least. That trade is refused; the conversion path must
|
|
534
|
-
# never wait on the supervisor. What non-blocking loses instead is in docs/
|
|
549
|
+
# never wait on the supervisor. What non-blocking loses instead is in docs/observability.md, and a test pins
|
|
535
550
|
# the flag so that a well-meaning fix has to argue with it.
|
|
536
551
|
stderr_reader.close
|
|
537
552
|
stderr_writer.fcntl Fcntl::F_SETFL, stderr_writer.fcntl(Fcntl::F_GETFL) | Fcntl::O_NONBLOCK
|
|
@@ -857,18 +872,23 @@ module HotCell
|
|
|
857
872
|
# the sweep must not signal it. An empty group is the common case, so ESRCH is expected. It is safe to
|
|
858
873
|
# kill the group by the leader's pid even though the leader is reaped, because the supervisor is single
|
|
859
874
|
# threaded and mints group leaders only in `spawn`, which cannot run between the `wait2` above and here.
|
|
875
|
+
#
|
|
876
|
+
# macOS answers EPERM rather than ESRCH for a group whose only members are zombies, and a tool killed
|
|
877
|
+
# with its worker stays one until launchd reaps it. Raised, it unwound `run` and ended the cell before
|
|
878
|
+
# this reap answered the caller. EPERM means no member received the signal, the same outcome as ESRCH.
|
|
860
879
|
def sweep_group(child)
|
|
861
880
|
Process.kill :KILL, -child.pid
|
|
862
|
-
rescue Errno::ESRCH
|
|
881
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
863
882
|
nil
|
|
864
883
|
end
|
|
865
884
|
|
|
866
885
|
# The whole process group, which is the worker and everything it started. Negative pid is the group.
|
|
867
886
|
# Falls back to the worker alone if the group is already gone, so a worker that died between the check
|
|
868
|
-
# and the signal is not an error.
|
|
887
|
+
# and the signal is not an error. A group answering EPERM falls back as well, for the reason
|
|
888
|
+
# `sweep_group` gives; the bare pid does not, because macOS signals a zombie pid without complaint.
|
|
869
889
|
def kill_group(child)
|
|
870
890
|
Process.kill :KILL, -child.pid
|
|
871
|
-
rescue Errno::ESRCH
|
|
891
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
872
892
|
begin
|
|
873
893
|
Process.kill :KILL, child.pid
|
|
874
894
|
rescue Errno::ESRCH
|
|
@@ -936,6 +956,12 @@ module HotCell
|
|
|
936
956
|
|
|
937
957
|
log.write "worker.reaped", pid: pid, slot: child.slot.number, served: child.served,
|
|
938
958
|
signal: signal_name(status), exit_code: status.exitstatus
|
|
959
|
+
|
|
960
|
+
# A worker that exited without serving a request is not replaced. This guards against a worker
|
|
961
|
+
# that crashes during its startup, for example after a bad deploy. A replacement would crash too,
|
|
962
|
+
# and the supervisor would fork again, with no requests arriving. Instead, the next request forks
|
|
963
|
+
# its own worker.
|
|
964
|
+
prefork if child.served.positive?
|
|
939
965
|
end
|
|
940
966
|
rescue Errno::ECHILD
|
|
941
967
|
nil
|
|
@@ -1237,6 +1263,13 @@ module HotCell
|
|
|
1237
1263
|
Registry.operations.each { |operation| operation.before_fork.each(&:call) }
|
|
1238
1264
|
end
|
|
1239
1265
|
|
|
1266
|
+
# Runs after `preload`, so that the objects the operations loaded are compacted too. Runs before the
|
|
1267
|
+
# first fork, so that each worker inherits the compacted heap. The compacted objects use fewer pages,
|
|
1268
|
+
# so a worker copies fewer pages when it writes to them.
|
|
1269
|
+
def warm_up
|
|
1270
|
+
Process.warmup
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1240
1273
|
|
|
1241
1274
|
def shutdown
|
|
1242
1275
|
refuse_queue "the cell is stopping"
|
data/lib/hot_cell/test_cell.rb
CHANGED
|
@@ -26,6 +26,9 @@ module HotCell
|
|
|
26
26
|
# which is the only place such a library may be loaded.
|
|
27
27
|
#
|
|
28
28
|
# HotCell::TestCell.boot(operations: -> { require "active_storage/hot_cell/server" })
|
|
29
|
+
#
|
|
30
|
+
# The cell's sockets go beneath `Dir.tmpdir`, and a Unix socket path holds 104 bytes on macOS, whose per-user
|
|
31
|
+
# `TMPDIR` spends about half of that. A suite on macOS should set a short `TMPDIR`, such as `/tmp`.
|
|
29
32
|
class TestCell
|
|
30
33
|
READY = "up"
|
|
31
34
|
|
|
@@ -70,6 +70,16 @@ module HotCell
|
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# A worker inherits the supervisor's GC counters at the fork. So this reports what the supervisor did to
|
|
74
|
+
# its heap before the fork.
|
|
75
|
+
class HeapState < HotCell::Operation
|
|
76
|
+
operation "test.heap_state"
|
|
77
|
+
|
|
78
|
+
def perform(_inputs, _outputs)
|
|
79
|
+
{ compactions: GC.stat(:compact_count) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
73
83
|
# Leaves its $HOME in a state the worker cannot remove, by taking write permission off a subdirectory
|
|
74
84
|
# that still has a file in it. A tool running as this user can do the same to a sibling's directory,
|
|
75
85
|
# which is why a removal that fails has to be reported rather than swallowed.
|
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: 1.0.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: 1.0.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: 1.0.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
|
|
@@ -43,6 +43,7 @@ files:
|
|
|
43
43
|
- lib/hot_cell/control.rb
|
|
44
44
|
- lib/hot_cell/counters.rb
|
|
45
45
|
- lib/hot_cell/filesystem.rb
|
|
46
|
+
- lib/hot_cell/health_operations.rb
|
|
46
47
|
- lib/hot_cell/limits.rb
|
|
47
48
|
- lib/hot_cell/log.rb
|
|
48
49
|
- lib/hot_cell/operation.rb
|
|
@@ -63,8 +64,9 @@ licenses:
|
|
|
63
64
|
- MIT
|
|
64
65
|
metadata:
|
|
65
66
|
homepage_uri: https://github.com/basecamp/hotcell
|
|
66
|
-
source_code_uri: https://github.com/basecamp/hotcell/tree/
|
|
67
|
-
changelog_uri: https://github.com/basecamp/hotcell/blob/
|
|
67
|
+
source_code_uri: https://github.com/basecamp/hotcell/tree/v1.0.0/hotcell-server
|
|
68
|
+
changelog_uri: https://github.com/basecamp/hotcell/blob/v1.0.0/CHANGELOG.md
|
|
69
|
+
documentation_uri: https://basecamp.github.io/hotcell/
|
|
68
70
|
bug_tracker_uri: https://github.com/basecamp/hotcell/issues
|
|
69
71
|
rubygems_mfa_required: 'true'
|
|
70
72
|
rdoc_options: []
|
|
@@ -81,7 +83,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
81
83
|
- !ruby/object:Gem::Version
|
|
82
84
|
version: '0'
|
|
83
85
|
requirements: []
|
|
84
|
-
rubygems_version: 4.0.
|
|
86
|
+
rubygems_version: 4.0.6
|
|
85
87
|
specification_version: 4
|
|
86
88
|
summary: 'Run a HotCell: the supervisor, the worker, and the operation API.'
|
|
87
89
|
test_files: []
|