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.
- checksums.yaml +7 -0
- data/CLAUDE.md +248 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/Rakefile +51 -0
- data/exe/siding +5 -0
- data/lib/siding/boot_component.rb +18 -0
- data/lib/siding/cli.rb +459 -0
- data/lib/siding/client.rb +417 -0
- data/lib/siding/error.rb +5 -0
- data/lib/siding/invocation.rb +35 -0
- data/lib/siding/life_cycle.rb +114 -0
- data/lib/siding/load_manifest.rb +475 -0
- data/lib/siding/logger.rb +89 -0
- data/lib/siding/platform.rb +37 -0
- data/lib/siding/project_key.rb +64 -0
- data/lib/siding/protocol.rb +154 -0
- data/lib/siding/restarter.rb +205 -0
- data/lib/siding/runtime.rb +165 -0
- data/lib/siding/server.rb +398 -0
- data/lib/siding/staleness.rb +191 -0
- data/lib/siding/version.rb +5 -0
- data/lib/siding/watch.rb +115 -0
- data/lib/siding/worker.rb +266 -0
- data/lib/siding.rb +27 -0
- metadata +97 -0
|
@@ -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
|
data/lib/siding/error.rb
ADDED
|
@@ -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
|