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,417 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "version"
5
+ require_relative "platform"
6
+ require_relative "project_key"
7
+ require_relative "runtime"
8
+ require_relative "logger"
9
+ require_relative "protocol"
10
+ require_relative "cli"
11
+
12
+ module Siding
13
+ class Client
14
+ ACTIVE_ENVIRONMENTS = %w[development test].freeze
15
+ DISABLE_OFF_VALUES = ["0", "false", "no", "off", ""].freeze
16
+ DEFAULT_BOOT_TIMEOUT = 90.0
17
+ NOTICE_AFTER = 0.75
18
+ FORWARDED_SIGNALS = %w[INT TERM QUIT TSTP WINCH].freeze
19
+ SUSPEND = "TSTP"
20
+ SIGNAL_LINES = FORWARDED_SIGNALS.to_h { |name| [name, "#{name}\n".freeze] }.freeze
21
+ RESTART = :restart
22
+ MAX_HANDOVERS = 3
23
+
24
+ attr_reader :argv, :env, :cwd, :logger
25
+
26
+ def initialize(argv, env: ENV, cwd: Dir.pwd)
27
+ @argv = argv.dup
28
+ @env = env
29
+ @cwd = cwd
30
+ @logger = Logger.new(env: env)
31
+ end
32
+
33
+ def run
34
+ return CLI.new(argv, self).run if CLI.management?(argv.first) || argv.empty?
35
+
36
+ reason = decline_reason
37
+ if reason
38
+ logger.debug("running unaccelerated: #{reason}")
39
+ return passthrough
40
+ end
41
+
42
+ accelerated_run
43
+ rescue Protocol::VersionMismatch => e
44
+ logger.debug(e.message)
45
+ replace_mismatched_server
46
+ rescue StandardError => e
47
+ logger.debug("running unaccelerated after #{e.class}: #{e.message}")
48
+ passthrough
49
+ ensure
50
+ logger.close
51
+ end
52
+
53
+ def app_root
54
+ return @app_root if defined?(@app_root)
55
+
56
+ @app_root = self.class.discover_app_root(cwd)
57
+ end
58
+
59
+ def self.discover_app_root(start)
60
+ dir = File.expand_path(start)
61
+ loop do
62
+ return dir if File.file?(File.join(dir, "config", "application.rb"))
63
+
64
+ parent = File.dirname(dir)
65
+ return nil if parent == dir
66
+
67
+ dir = parent
68
+ end
69
+ end
70
+
71
+ def project_key
72
+ @project_key ||= app_root && ProjectKey.for(app_root, env:)
73
+ end
74
+
75
+ def runtime
76
+ @runtime ||= project_key && Runtime.for(project_key, env:)
77
+ end
78
+
79
+ def app_env = ProjectKey.app_env_from(env)
80
+
81
+ def warm_up
82
+ reason = unusable_reason
83
+ if reason
84
+ logger.debug("not warming up: #{reason}")
85
+ return false
86
+ end
87
+
88
+ runtime.prepare
89
+ # The connection attempt under the boot lock is the only authoritative answer to "was one
90
+ # already warm?", so the distinction is carried out from there rather than reconstructed by
91
+ # the caller from a record that can go stale between the two reads.
92
+ outcome, socket = with_boot_lock do
93
+ connected = try_connect
94
+ next [:already_warm, connected] if connected
95
+
96
+ runtime.discard_socket
97
+ [:booted, boot_server]
98
+ end
99
+ return false if socket.nil?
100
+
101
+ socket.close
102
+ outcome
103
+ rescue StandardError => e
104
+ logger.debug("could not warm up: #{e.class}: #{e.message}")
105
+ false
106
+ end
107
+
108
+ def decline_reason
109
+ return "#{argv.first} is not in the accelerated set" unless CLI.accelerated?(argv)
110
+
111
+ unusable_reason
112
+ end
113
+
114
+ def unusable_reason
115
+ return "SIDING_DISABLE is set" if disabled?
116
+ return Platform.unsupported_reason unless Platform.supported?
117
+ return "no Rails application found above #{cwd}" if app_root.nil?
118
+ return "#{app_env.inspect} is not an accelerated environment" unless ACTIVE_ENVIRONMENTS.include?(app_env)
119
+
120
+ runtime.unavailable_reason
121
+ end
122
+
123
+ private
124
+
125
+ def disabled?
126
+ value = env["SIDING_DISABLE"]
127
+ return false if value.nil?
128
+
129
+ !DISABLE_OFF_VALUES.include?(value.strip.downcase)
130
+ end
131
+
132
+ def passthrough
133
+ command = passthrough_command
134
+ logger.close
135
+ exec(*command)
136
+ rescue SystemCallError => e
137
+ warn("siding: #{command.first}: #{e.message}")
138
+ 127
139
+ end
140
+
141
+ def passthrough_command
142
+ return argv if app_root.nil? || !File.file?(File.join(app_root, "Gemfile"))
143
+ return argv if argv.first == "bundle"
144
+
145
+ ["bundle", "exec", *argv]
146
+ end
147
+
148
+ def accelerated_run
149
+ runtime.prepare
150
+
151
+ (MAX_HANDOVERS + 1).times do |attempt|
152
+ socket = connect_or_boot
153
+ return passthrough if socket.nil?
154
+
155
+ result = hand_over(socket, restarted: attempt.positive?)
156
+ return result unless result == RESTART
157
+ end
158
+
159
+ logger.debug("the warm application kept being replaced; running unaccelerated")
160
+
161
+ passthrough
162
+ end
163
+
164
+ def hand_over(socket, restarted: false)
165
+ return RESTART unless greet(socket)
166
+
167
+ Protocol.write_message(socket, Protocol::RUN, argv:, env: env.to_h, cwd:, pid: Process.pid, restarted:)
168
+ Protocol.send_streams(socket)
169
+ forwarding_signals(socket) { await_result(socket) }
170
+ ensure
171
+ socket.close unless socket.closed?
172
+ end
173
+
174
+ def forwarding_signals(socket)
175
+ @signal_read, @signal_write = IO.pipe
176
+ @displaced_traps = install_forwarding_traps
177
+ @forwarder = Thread.new { forward_signals(socket) }
178
+ yield
179
+ ensure
180
+ stop_forwarding
181
+ end
182
+
183
+ def install_forwarding_traps
184
+ FORWARDED_SIGNALS.to_h { |name| [name, install_forwarding_trap(name)] }
185
+ end
186
+
187
+ def install_forwarding_trap(name)
188
+ line = SIGNAL_LINES[name]
189
+ Signal.trap(name) do
190
+ @signal_write.write_nonblock(line)
191
+ rescue StandardError
192
+ # The command finished, or the pipe is full because five thousand resize events arrived
193
+ # while the forwarder was busy.
194
+ nil
195
+ end
196
+ rescue ArgumentError, SystemCallError
197
+ nil
198
+ end
199
+
200
+ def forward_signals(socket)
201
+ while (line = @signal_read.gets)
202
+ name = line.chomp
203
+ Protocol.write_message(socket, Protocol::SIGNAL, name:)
204
+ suspend if name == SUSPEND
205
+ end
206
+ rescue StandardError => e
207
+ # The socket is gone, which means the command is over.
208
+ logger.debug("stopped forwarding signals: #{e.class}")
209
+ end
210
+
211
+ def suspend
212
+ Signal.trap(SUSPEND, "SYSTEM_DEFAULT")
213
+ Process.kill(SUSPEND, Process.pid)
214
+ install_forwarding_trap(SUSPEND)
215
+ continue_worker
216
+ end
217
+
218
+ def continue_worker
219
+ pid = @worker_pid
220
+ return if pid.nil?
221
+
222
+ Process.kill("CONT", -pid)
223
+ rescue SystemCallError
224
+ # The command finished while we were stopped. Its group is gone, and continuing it is moot.
225
+ nil
226
+ end
227
+
228
+ def stop_forwarding
229
+ @displaced_traps&.each do |name, previous|
230
+ Signal.trap(name, previous.nil? ? "DEFAULT" : previous)
231
+ rescue ArgumentError, SystemCallError
232
+ nil
233
+ end
234
+
235
+ @displaced_traps = nil
236
+ @signal_write&.close unless @signal_write&.closed?
237
+ @forwarder&.join(1)
238
+ @signal_read&.close unless @signal_read&.closed?
239
+ @signal_write = @signal_read = @forwarder = nil
240
+ end
241
+
242
+ def greet(socket)
243
+ Protocol.client_handshake(socket)
244
+ true
245
+ rescue Protocol::TruncatedMessage, SystemCallError => e
246
+ logger.debug("the warm application went away before it answered: #{e.class}")
247
+ false
248
+ end
249
+
250
+ def connect_or_boot
251
+ socket = try_connect
252
+ return socket if socket
253
+
254
+ socket = await_replacement
255
+ return socket if socket
256
+
257
+ with_boot_lock do
258
+ socket = try_connect
259
+ next socket if socket
260
+
261
+ runtime.discard_socket
262
+ boot_server
263
+ end
264
+ end
265
+
266
+ def await_replacement
267
+ pid = @replacement_pid
268
+ @replacement_pid = nil
269
+ return nil if pid.nil?
270
+
271
+ logger.debug("waiting for the replacement already booting (#{pid})")
272
+ await_socket(pid)
273
+ end
274
+
275
+ def try_connect
276
+ require "socket"
277
+ UNIXSocket.new(runtime.socket_path)
278
+ rescue SystemCallError
279
+ # ENOENT (never booted), ECONNREFUSED (socket file outlived its server).
280
+ nil
281
+ end
282
+
283
+ def with_boot_lock
284
+ File.open(runtime.lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
285
+ lock.flock(File::LOCK_EX)
286
+ yield
287
+ end
288
+ end
289
+
290
+ def boot_server
291
+ require_relative "server"
292
+
293
+ logger.debug("booting a warm application for #{project_key.label}")
294
+ pid = Server.spawn(project_key:, runtime:, env:)
295
+ await_socket(pid)
296
+ end
297
+
298
+ def await_socket(server_pid)
299
+ started_at = now
300
+ deadline = started_at + boot_timeout
301
+ notified = false
302
+
303
+ loop do
304
+ socket = try_connect
305
+ return socket if socket
306
+
307
+ if server_died?(server_pid)
308
+ report_boot_failure
309
+ return nil
310
+ end
311
+
312
+ return nil if now >= deadline
313
+
314
+ notified = announce_wait(started_at) unless notified
315
+ sleep 0.02
316
+ end
317
+ end
318
+
319
+ def announce_wait(started_at)
320
+ return false if now < started_at + NOTICE_AFTER
321
+
322
+ logger.notice("booting #{File.basename(app_root)} (#{app_env})")
323
+ true
324
+ end
325
+
326
+ def server_died?(pid)
327
+ return true if pid.nil?
328
+
329
+ !Process.waitpid(pid, Process::WNOHANG).nil?
330
+ rescue Errno::ECHILD
331
+ !alive?(pid)
332
+ end
333
+
334
+ def alive?(pid)
335
+ Process.kill(0, pid)
336
+ true
337
+ rescue Errno::ESRCH
338
+ false
339
+ rescue SystemCallError
340
+ true
341
+ end
342
+
343
+ def report_boot_failure
344
+ logger.debug("the application did not boot; its output is in #{runtime.boot_log_path}")
345
+ end
346
+
347
+ def await_result(socket)
348
+ loop do
349
+ message = Protocol.read_message(socket)
350
+ # The server went away without a verdict. Nothing ran, or nothing we can account for, so
351
+ # the honest answer is a failure the developer can see rather than a fabricated zero.
352
+ return 1 if message.nil?
353
+
354
+ case message.type
355
+ when Protocol::BOOTING
356
+ logger.notice(booting_notice(message))
357
+ if message["restart"]
358
+ @replacement_pid = message["replacement_pid"]
359
+ return RESTART
360
+ end
361
+ when Protocol::BOOT_FAILED
362
+ logger.notice(message["output"].to_s.chomp)
363
+ return 1
364
+ when Protocol::STARTED
365
+ @worker_pid = message["pid"]
366
+ when Protocol::FINISHED
367
+ return reproduce(message)
368
+ end
369
+ end
370
+ end
371
+
372
+ def booting_notice(message)
373
+ notice = +"waiting for the application: #{message['reason']}"
374
+ seconds = message["estimated_seconds"]
375
+ notice << " (about #{seconds}s)" if seconds
376
+ notice
377
+ end
378
+
379
+ def reproduce(message)
380
+ signal = message["signal"]
381
+ return message["exit_code"].to_i if signal.nil?
382
+
383
+ die_of(signal.to_i)
384
+ end
385
+
386
+ def die_of(number)
387
+ name = Signal.signame(number)
388
+ return 128 + number if name.nil?
389
+
390
+ logger.close
391
+ Signal.trap(name, "SYSTEM_DEFAULT")
392
+ Process.kill(name, Process.pid)
393
+ # Reached only for a signal this process cannot die of -- one the platform ignores by default.
394
+ # The conventional encoding is then the best available answer, and it is at least non-zero.
395
+ sleep 0.05
396
+ 128 + number
397
+ rescue ArgumentError, SystemCallError
398
+ 128 + number
399
+ end
400
+
401
+ def replace_mismatched_server
402
+ CLI.new(["stop"], self).run
403
+ accelerated_run
404
+ rescue StandardError => e
405
+ logger.debug("running unaccelerated after failed replacement: #{e.message}")
406
+ passthrough
407
+ end
408
+
409
+ def boot_timeout
410
+ value = env["SIDING_TIMEOUT"]
411
+ timeout = value.to_f
412
+ timeout.positive? ? timeout : DEFAULT_BOOT_TIMEOUT
413
+ end
414
+
415
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
416
+ end
417
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Siding
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Siding
4
+ module Invocation
5
+ RESOLUTION_KEY = "SIDING_RESOLUTION"
6
+ REVISION_KEY = "SIDING_REVISION"
7
+ BOOT_SECONDS_KEY = "SIDING_BOOT_SECONDS"
8
+
9
+ RESOLUTIONS = %w[fresh reloaded_in_worker rebuild].freeze
10
+
11
+ module_function
12
+
13
+ def accelerated?(env = ENV) = !env[RESOLUTION_KEY].nil?
14
+ def resolution(env = ENV) = env[RESOLUTION_KEY]
15
+ def revision(env = ENV) = env[REVISION_KEY]
16
+
17
+ def boot_seconds(env = ENV)
18
+ value = env[BOOT_SECONDS_KEY]
19
+ return nil if value.nil? || value.empty?
20
+
21
+ Float(value)
22
+ rescue ArgumentError, TypeError
23
+ nil
24
+ end
25
+
26
+ def to_h(env = ENV)
27
+ {
28
+ accelerated: accelerated?(env),
29
+ resolution: resolution(env),
30
+ revision: revision(env),
31
+ boot_seconds: boot_seconds(env)
32
+ }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Siding
6
+ module LifeCycle
7
+ class HookError < Error
8
+ attr_reader :hook_name, :phase, :cause_error
9
+
10
+ def initialize(hook_name:, phase:, cause_error:)
11
+ @hook_name = hook_name
12
+ @phase = phase
13
+ @cause_error = cause_error
14
+ super("siding #{phase} hook #{hook_name.inspect} raised #{cause_error.class}: #{cause_error.message}")
15
+ end
16
+ end
17
+
18
+ Hook = Struct.new(:name, :block, keyword_init: true)
19
+
20
+ SERVER_SIGNALS = %w[TERM INT].freeze
21
+
22
+ class << self
23
+ def before_fork(name = nil, &block)
24
+ register(before_fork_hooks, name, block)
25
+ end
26
+
27
+ def after_fork(name = nil, &block)
28
+ register(after_fork_hooks, name, block)
29
+ end
30
+
31
+ def before_fork_hooks = @before_fork_hooks ||= []
32
+
33
+ def after_fork_hooks = @after_fork_hooks ||= []
34
+
35
+ def reset!
36
+ @before_fork_hooks = []
37
+ @after_fork_hooks = []
38
+ end
39
+
40
+ # For Server
41
+
42
+ def prepare_for_fork
43
+ disconnect_database
44
+ run_hooks(before_fork_hooks, :before_fork)
45
+ end
46
+
47
+ def remember_signal_handler(signal, previous)
48
+ inherited_signal_handlers[signal] = previous
49
+ end
50
+
51
+ def inherited_signal_handlers = @inherited_signal_handlers ||= {}
52
+
53
+ # For Worker
54
+
55
+ def repair_after_fork
56
+ restore_inherited_signal_handlers
57
+ reseed_random
58
+ reconnect_database
59
+ run_hooks(after_fork_hooks, :after_fork)
60
+ end
61
+
62
+ def restore_inherited_signal_handlers
63
+ SERVER_SIGNALS.each do |signal|
64
+ previous = inherited_signal_handlers[signal]
65
+ Signal.trap(signal, previous.nil? ? "DEFAULT" : previous)
66
+ rescue ArgumentError, SystemCallError
67
+ nil
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def register(list, name, block)
74
+ raise ArgumentError, "a fork hook needs a block" if block.nil?
75
+
76
+ list << Hook.new(name: name || describe(block), block: block)
77
+ block
78
+ end
79
+
80
+ def run_hooks(hooks, phase)
81
+ hooks.each do |hook|
82
+ hook.block.call
83
+ rescue StandardError, ScriptError => e
84
+ raise HookError.new(hook_name: hook.name, phase: phase, cause_error: e)
85
+ end
86
+ end
87
+
88
+ def describe(block)
89
+ location = block.source_location
90
+ location ? "#{location[0]}:#{location[1]}" : "anonymous"
91
+ end
92
+
93
+ def disconnect_database
94
+ return unless defined?(::ActiveRecord::Base)
95
+
96
+ ::ActiveRecord::Base.connection_handler.clear_all_connections!
97
+ rescue StandardError
98
+ nil
99
+ end
100
+
101
+ def reconnect_database
102
+ return unless defined?(::ActiveRecord::Base)
103
+
104
+ ::ActiveRecord::Base.connection_handler.clear_all_connections!
105
+ rescue StandardError
106
+ nil
107
+ end
108
+
109
+ def reseed_random
110
+ srand
111
+ end
112
+ end
113
+ end
114
+ end