siding 0.0.1

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.
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ require_relative "error"
7
+
8
+ module Siding
9
+ module Protocol
10
+ VERSION = 1
11
+
12
+ LENGTH_BYTES = 4
13
+ LENGTH_FORMAT = "N"
14
+
15
+ MAX_MESSAGE_BYTES = 1024 * 1024
16
+
17
+ # Client -> server.
18
+ HELLO = "Hello"
19
+ RUN = "Run"
20
+ SIGNAL = "Signal"
21
+ STATUS = "Status"
22
+ STOP = "Stop"
23
+
24
+ # Server -> client.
25
+ WELCOME = "Welcome"
26
+ VERSION_MISMATCH = "VersionMismatch"
27
+ BOOTING = "Booting"
28
+ BOOT_FAILED = "BootFailed"
29
+ STARTED = "Started"
30
+ FINISHED = "Finished"
31
+ STATUS_REPORT = "StatusReport"
32
+
33
+ class ProtocolError < Error; end
34
+ class TruncatedMessage < ProtocolError; end
35
+ class MessageTooLarge < ProtocolError; end
36
+
37
+ class VersionMismatch < ProtocolError
38
+ attr_reader :server_version, :client_version
39
+
40
+ def initialize(server_version:, client_version: VERSION)
41
+ @server_version = server_version
42
+ @client_version = client_version
43
+ super("server speaks protocol version #{server_version}, this client speaks #{client_version}")
44
+ end
45
+ end
46
+
47
+ Message = Struct.new(:type, :payload, keyword_init: true) do
48
+ def [](key) = payload[key]
49
+ end
50
+
51
+ module_function
52
+
53
+ def write_message(io, type, payload = {})
54
+ body = JSON.generate({ "type" => type }.merge(stringify(payload)))
55
+ raise MessageTooLarge, "message of #{body.bytesize} bytes exceeds #{MAX_MESSAGE_BYTES}" if body.bytesize > MAX_MESSAGE_BYTES
56
+
57
+ # Length and body in a single write. Two writes would give a reader a window in which a
58
+ # crash leaves a valid-looking prefix with nothing behind it -- exactly the state the
59
+ # framing is here to make unrepresentable.
60
+ io.write([body.bytesize].pack(LENGTH_FORMAT) + body)
61
+ io.flush if io.respond_to?(:flush)
62
+ body.bytesize
63
+ end
64
+
65
+ def read_message(io)
66
+ header = read_exactly(io, LENGTH_BYTES, allow_eof: true)
67
+ return nil if header.nil?
68
+
69
+ length = header.unpack1(LENGTH_FORMAT)
70
+ raise MessageTooLarge, "peer announced #{length} bytes, over the #{MAX_MESSAGE_BYTES} limit" if
71
+ length > MAX_MESSAGE_BYTES
72
+
73
+ body = read_exactly(io, length)
74
+ parsed = begin
75
+ JSON.parse(body)
76
+ rescue JSON::ParserError => e
77
+ raise ProtocolError, "malformed message body: #{e.message}"
78
+ end
79
+
80
+ raise ProtocolError, "message has no type" unless parsed.is_a?(Hash) && parsed["type"]
81
+
82
+ Message.new(type: parsed.delete("type"), payload: parsed)
83
+ end
84
+
85
+ def read_exactly(io, count, allow_eof: false)
86
+ return +"" if count.zero?
87
+
88
+ buffer = +""
89
+ while buffer.bytesize < count
90
+ chunk = read_some(io, count - buffer.bytesize)
91
+ if chunk.nil? || chunk.empty?
92
+ return nil if buffer.empty? && allow_eof
93
+
94
+ raise TruncatedMessage, "peer sent #{buffer.bytesize} of #{count} bytes before closing"
95
+ end
96
+ buffer << chunk
97
+ end
98
+ buffer
99
+ end
100
+
101
+ def read_some(io, count)
102
+ io.respond_to?(:recv) ? io.recv(count) : io.read(count)
103
+ end
104
+
105
+ def stringify(payload)
106
+ payload.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
107
+ end
108
+
109
+ STREAM_ORDER = %i[stdin stdout stderr].freeze
110
+
111
+ def send_streams(io, stdin: $stdin, stdout: $stdout, stderr: $stderr)
112
+ [stdin, stdout, stderr].each { |stream| io.send_io(stream) }
113
+ STREAM_ORDER
114
+ end
115
+
116
+ def receive_streams(io)
117
+ STREAM_ORDER.each_with_object({}) { |name, streams| streams[name] = io.recv_io }
118
+ rescue EOFError, SocketError, SystemCallError => e
119
+ # A peer that dies mid-pass surfaces as `SocketError` ("file descriptor was not passed")
120
+ # rather than as EOF, because the control message arrives empty rather than not at all.
121
+ # Both mean the same thing here: the descriptors we were promised are not coming.
122
+ raise TruncatedMessage, "peer closed while passing descriptors: #{e.message}"
123
+ end
124
+
125
+ def client_handshake(io)
126
+ write_message(io, HELLO, protocol_version: VERSION)
127
+ reply = read_message(io)
128
+ raise TruncatedMessage, "server closed during handshake" if reply.nil?
129
+
130
+ case reply.type
131
+ when WELCOME
132
+ reply["protocol_version"]
133
+ when VERSION_MISMATCH
134
+ raise VersionMismatch.new(server_version: reply["protocol_version"])
135
+ else
136
+ raise ProtocolError, "unexpected #{reply.type.inspect} in response to #{HELLO}"
137
+ end
138
+ end
139
+
140
+ def server_handshake(io)
141
+ hello = read_message(io)
142
+ raise TruncatedMessage, "client closed during handshake" if hello.nil?
143
+ raise ProtocolError, "expected #{HELLO}, got #{hello.type.inspect}" unless hello.type == HELLO
144
+
145
+ if hello["protocol_version"] == VERSION
146
+ write_message(io, WELCOME, protocol_version: VERSION)
147
+ true
148
+ else
149
+ write_message(io, VERSION_MISMATCH, protocol_version: VERSION)
150
+ false
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "staleness"
4
+ require_relative "watch"
5
+
6
+ module Siding
7
+ class Restarter
8
+ MIN_INTERVAL = 3.0
9
+ MAX_INTERVAL = 60.0
10
+ IDLE_FRACTION = 0.1
11
+ SETTLE = 1.0
12
+ HANDOVER_INTERVAL = 0.5
13
+
14
+ QUIET = :quiet
15
+ SETTLING = :settling
16
+ BOOT = :boot
17
+ SUPERSEDED = :superseded
18
+
19
+ attr_reader :manifest, :project_key, :runtime, :logger
20
+
21
+ def initialize(manifest:, project_key:, runtime:, logger:, env: ENV, clock: nil, busy: nil, superseded: nil, on_superseded: nil, spawner: nil)
22
+ @manifest = manifest
23
+ @project_key = project_key
24
+ @runtime = runtime
25
+ @logger = logger
26
+ @env = env
27
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
28
+ @busy = busy || -> { false }
29
+ @superseded = superseded || -> { false }
30
+ @on_superseded = on_superseded
31
+ @spawner = spawner
32
+ @gate = Mutex.new
33
+ @signal = Thread::Queue.new
34
+ @running = false
35
+ @last_activity = @clock.call
36
+ end
37
+
38
+ def start
39
+ return self if @thread
40
+
41
+ @watch = start_watch
42
+
43
+ @running = true
44
+ @thread = Thread.new { poll_loop }
45
+ @thread.name = "siding-restarter" if @thread.respond_to?(:name=)
46
+ self
47
+ end
48
+
49
+ def stop
50
+ thread = @thread
51
+ @running = false
52
+ @thread = nil
53
+ @watch&.stop
54
+ return self if thread.nil?
55
+
56
+ @signal << :stop
57
+ thread.join(1) || thread.kill
58
+ self
59
+ end
60
+
61
+ def watch_mode
62
+ @watch ? @watch.mode_label : "poll"
63
+ end
64
+
65
+ def busy!
66
+ @last_activity = @clock.call
67
+ end
68
+ alias idle! busy!
69
+
70
+ def idle_seconds(now = @clock.call)
71
+ now - @last_activity
72
+ end
73
+
74
+ def interval_for(idle)
75
+ [[idle * IDLE_FRACTION, MIN_INTERVAL].max, MAX_INTERVAL].min
76
+ end
77
+
78
+ def quiesce
79
+ @gate.synchronize { yield }
80
+ end
81
+
82
+ def around_fork
83
+ quiesce do
84
+ @watch&.stop
85
+ yield
86
+ ensure
87
+ @watch = start_watch if @running
88
+ end
89
+ end
90
+
91
+ def poll(now: @clock.call)
92
+ decision = tick(now: now)
93
+ act(decision)
94
+ decision
95
+ end
96
+
97
+ def tick(now: @clock.call)
98
+ return SUPERSEDED if @superseded.call
99
+ return QUIET if @busy.call
100
+
101
+ verdict = Staleness.validate(manifest, env: @env)
102
+ return settled(QUIET) if verdict.fresh?
103
+
104
+ settle(verdict.revision_label, now)
105
+ end
106
+
107
+ def replacement_pid
108
+ pid = @replacement_pid
109
+ return nil if pid.nil?
110
+ return pid if alive?(pid)
111
+
112
+ @replacement_pid = nil
113
+ end
114
+
115
+ private
116
+
117
+ def start_watch
118
+ Watch.start(manifest:, env: @env, logger:) do
119
+ @signal << :changed if @signal.empty?
120
+ end
121
+ end
122
+
123
+ def poll_loop
124
+ while @running
125
+ wait(wait_interval)
126
+ break unless @running
127
+
128
+ @last_decision = quiesce { poll }
129
+ end
130
+ rescue StandardError => e
131
+ logger.debug("restarter stopped: #{e.class}: #{e.message}")
132
+ end
133
+
134
+ def wait(seconds)
135
+ @signal.pop(timeout: seconds)
136
+ end
137
+
138
+ def wait_interval
139
+ return SETTLE if @last_decision == SETTLING
140
+ return HANDOVER_INTERVAL if replacement_pid
141
+ return MAX_INTERVAL if @watch&.watching?
142
+
143
+ interval_for(idle_seconds)
144
+ end
145
+
146
+ def act(decision)
147
+ case decision
148
+ when BOOT then spawn_replacement
149
+ when SUPERSEDED then withdraw
150
+ end
151
+ end
152
+
153
+ def settle(label, now)
154
+ if label != @pending_label
155
+ @pending_label = label
156
+ @pending_since = now
157
+ return SETTLING
158
+ end
159
+
160
+ return SETTLING if now - @pending_since < SETTLE
161
+ return QUIET if label == @attempted_label
162
+
163
+ @attempted_label = label
164
+ BOOT
165
+ end
166
+
167
+ def settled(decision)
168
+ @pending_label = nil
169
+ @pending_since = nil
170
+ decision
171
+ end
172
+
173
+ def spawn_replacement
174
+ require_relative "server"
175
+
176
+ @replacement_pid = spawner.call
177
+ reaper = Process.detach(@replacement_pid) if @replacement_pid
178
+ # A reaper that finds the pid was never ours has nothing to report to anyone's terminal.
179
+ reaper&.report_on_exception = false
180
+ logger.debug("restarter booting #{@pending_label} as #{@replacement_pid}")
181
+ @replacement_pid
182
+ rescue StandardError => e
183
+ logger.debug("restarter could not boot a replacement: #{e.message}")
184
+ @replacement_pid = nil
185
+ end
186
+
187
+ def spawner
188
+ @spawner || -> { Server.spawn(project_key:, runtime:, env: @env) }
189
+ end
190
+
191
+ def withdraw
192
+ @running = false
193
+ @on_superseded&.call
194
+ end
195
+
196
+ def alive?(pid)
197
+ Process.kill(0, pid)
198
+ true
199
+ rescue Errno::ESRCH
200
+ false
201
+ rescue SystemCallError
202
+ true
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ require_relative "platform"
7
+
8
+ module Siding
9
+ class Runtime
10
+ DIRECTORY_MODE = 0o700
11
+
12
+ NAMESPACE = "siding"
13
+
14
+ # Used when XDG_RUNTIME_DIR is unset -- common on macOS, where there is no such convention.
15
+ # `~/.local/state` is the XDG base-directory spec's home for state that should persist between
16
+ # restarts but is not configuration.
17
+ FALLBACK_ROOT = File.join(".local", "state", NAMESPACE)
18
+
19
+ class Unavailable < Error
20
+ attr_reader :path
21
+
22
+ def initialize(message, path: nil)
23
+ @path = path
24
+ super(message)
25
+ end
26
+ end
27
+
28
+ attr_reader :project_key, :root
29
+
30
+ def self.for(project_key, env: ENV)
31
+ new(project_key: project_key, root: root_for(env))
32
+ end
33
+
34
+ def self.root_for(env)
35
+ xdg = env["XDG_RUNTIME_DIR"]
36
+ return File.join(xdg, NAMESPACE) if xdg && !xdg.empty?
37
+
38
+ File.join(Dir.home, FALLBACK_ROOT)
39
+ end
40
+
41
+ def initialize(project_key:, root:)
42
+ @project_key = project_key
43
+ @root = root
44
+ end
45
+
46
+ def dir = File.join(root, project_key.digest)
47
+ def socket_path = File.join(dir, "sock")
48
+ def lock_path = File.join(dir, "lock")
49
+ def server_info_path = File.join(dir, "server.json")
50
+ def boot_log_path = File.join(dir, "boot.log")
51
+ def events_path = File.join(dir, "events.jsonl")
52
+ def log_path = File.join(dir, "siding.log")
53
+
54
+ def server_info
55
+ info = JSON.parse(File.read(server_info_path))
56
+ info.is_a?(Hash) ? info : nil
57
+ rescue SystemCallError, JSON::ParserError
58
+ nil
59
+ end
60
+
61
+ def live_server_info
62
+ info = server_info
63
+ return nil if info.nil?
64
+
65
+ pid = info["pid"]
66
+ return nil unless pid.is_a?(Integer) && self.class.process_alive?(pid)
67
+
68
+ info
69
+ end
70
+
71
+ def server_pid = live_server_info&.[]("pid")
72
+
73
+ def discard_socket
74
+ File.unlink(socket_path)
75
+ true
76
+ rescue SystemCallError
77
+ false
78
+ end
79
+
80
+ def discard_records
81
+ [socket_path, server_info_path].each do |path|
82
+ File.unlink(path)
83
+ rescue SystemCallError
84
+ nil
85
+ end
86
+ true
87
+ end
88
+
89
+ def self.process_alive?(pid)
90
+ Process.kill(0, pid)
91
+ true
92
+ rescue Errno::ESRCH, Errno::EPERM
93
+ false
94
+ rescue SystemCallError
95
+ # Anything else means the question could not be asked, and a live process wrongly called dead
96
+ # would have us boot a second server over a working one.
97
+ true
98
+ end
99
+
100
+ def prepare
101
+ check_socket_path_length
102
+ ensure_directory(root)
103
+ ensure_directory(dir)
104
+ self
105
+ end
106
+
107
+ def prepared?
108
+ File.directory?(dir) && safe_directory?(dir) && socket_path_within_limit?
109
+ end
110
+
111
+ def unavailable_reason
112
+ prepare
113
+ nil
114
+ rescue Unavailable => e
115
+ e.message
116
+ end
117
+
118
+ def socket_path_within_limit?
119
+ socket_path.bytesize <= Platform::UNIX_SOCKET_PATH_LIMIT - 1
120
+ end
121
+
122
+ private
123
+
124
+ def ensure_directory(path)
125
+ FileUtils.mkdir_p(path, mode: DIRECTORY_MODE)
126
+ File.chmod(DIRECTORY_MODE, path)
127
+ verify_ownership(path)
128
+ rescue SystemCallError => e
129
+ raise Unavailable.new("cannot use runtime directory #{path}: #{e.message}", path: path)
130
+ end
131
+
132
+ def verify_ownership(path)
133
+ stat = File.stat(path)
134
+ unless stat.uid == Process.uid
135
+ raise Unavailable.new(
136
+ "runtime directory #{path} is owned by uid #{stat.uid}, not #{Process.uid}", path: path
137
+ )
138
+ end
139
+
140
+ return if (stat.mode & 0o077).zero?
141
+
142
+ raise Unavailable.new(
143
+ "runtime directory #{path} is accessible to other users (mode #{format('%o', stat.mode & 0o777)})",
144
+ path: path
145
+ )
146
+ end
147
+
148
+ def safe_directory?(path)
149
+ stat = File.stat(path)
150
+ stat.uid == Process.uid && (stat.mode & 0o077).zero?
151
+ rescue SystemCallError
152
+ false
153
+ end
154
+
155
+ def check_socket_path_length
156
+ return if socket_path_within_limit?
157
+
158
+ raise Unavailable.new(
159
+ "socket path #{socket_path} is #{socket_path.bytesize} bytes, over the " \
160
+ "#{Platform::UNIX_SOCKET_PATH_LIMIT - 1}-byte limit for Unix domain sockets",
161
+ path: socket_path
162
+ )
163
+ end
164
+ end
165
+ end