expedite 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 492f096e30142c23ad6849f79864355a96fdb042990d43aaaaa85516643a6338
4
+ data.tar.gz: 29d32100e9df804748a684fdce1636907fe844996adb702e193369c434ffcd8a
5
+ SHA512:
6
+ metadata.gz: d5f5fce18a565bbc3ea616bf1a5db621a1708eae3f6d1ce6ae6bb5c32d266859983ed35fefc8905184241602bc1554128627adf4ff3281f256056147351c9d2e
7
+ data.tar.gz: f85be9f1dfe698b1875fd0008d9a0c4ed324f5ddeb1b9562b83431864c4eaa664e1c83e748b680fb6877ecdd00c71a8160226613d52ae6f5338e7ec2195d7809
data/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # Expedite
2
+
3
+ ![main](https://github.com/johnny-lai/expedite/actions/workflows/ruby.yml/badge.svg)
4
+
5
+ Expedite is a Ruby preloader manager that allows commands to be executed against
6
+ preloaded Ruby applications. Preloader applications can derive from other preloaders, allowing
7
+ derivatives to start faster.
8
+
9
+ ## Usage
10
+
11
+ Register variants and commands in `expedite_helper.rb`. For example:
12
+
13
+ ```
14
+ Expedite::Variants.register('base' do |name|
15
+ puts "Base started"
16
+ end
17
+ ```
18
+
19
+ You can register variants that are based on other variants, and you can also have wildcard
20
+ matchers.
21
+ ```
22
+ Expedite::Variants.register('development/*', parent: 'base') do |name|
23
+ customer = File.basename(name)
24
+ puts "Starting development for #{customer}"
25
+ end
26
+ ```
27
+
28
+ You register commands by creating classes in the `Expedite::Command` module. For example,
29
+ this defines a `custom` command.
30
+
31
+ ```
32
+ Expedite::Commands.register("custom") do
33
+ puts "[#{Expedite.variant}] sleeping for 5"
34
+ puts "$sleep_parent = #{$sleep_parent}"
35
+ puts "$sleep_child = #{$sleep_child}"
36
+ puts "[#{Expedite.variant}] done"
37
+ end
38
+ ```
39
+
40
+ Then you can execute a command in the variant using:
41
+ ```
42
+ Expedite.v("development/abc").call("custom")
43
+ ```
data/bin/expedite ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ lib = File.expand_path("../../lib", __FILE__)
4
+ $LOAD_PATH.unshift lib
5
+
6
+ require 'expedite/cli'
7
+ Expedite::Cli.run(ARGV)
data/lib/expedite.rb ADDED
@@ -0,0 +1,19 @@
1
+ require "expedite/client"
2
+
3
+ module Expedite
4
+ ##
5
+ # Returns a client to dispatch actions to the specified variant
6
+ def self.variant(variant)
7
+ @clients ||= Hash.new do |h, k|
8
+ Client.new(env: Env.new, variant: variant)
9
+ end
10
+ @clients[variant]
11
+ end
12
+
13
+ ##
14
+ # Alias for self.variant
15
+ def self.v(variant)
16
+ self.variant(variant)
17
+ end
18
+ end
19
+
@@ -0,0 +1,316 @@
1
+ # Based on https://github.com/rails/spring/blob/master/lib/spring/application.rb
2
+ require 'json'
3
+ require 'pty'
4
+ require 'set'
5
+ require 'socket'
6
+ require 'expedite/commands'
7
+ require 'expedite/env'
8
+ require 'expedite/failsafe_thread'
9
+ require 'expedite/load_helper'
10
+ require 'expedite/signals'
11
+
12
+ module Expedite
13
+ def self.variant
14
+ app.variant
15
+ end
16
+
17
+ def self.app=(app)
18
+ @app = app
19
+ end
20
+ def self.app
21
+ @app
22
+ end
23
+
24
+ class Application
25
+ include LoadHelper
26
+ include Signals
27
+
28
+ attr_reader :variant
29
+ attr_reader :manager, :env, :original_env
30
+
31
+ def initialize(variant, manager, original_env, env = Env.new)
32
+ @variant = variant
33
+ @manager = manager
34
+ @original_env = original_env
35
+ @env = env
36
+ @mutex = Mutex.new
37
+ @waiting = Set.new
38
+ @preloaded = false
39
+ @state = :initialized
40
+ @interrupt = IO.pipe
41
+ end
42
+
43
+ def boot
44
+ # This is necessary for the terminal to work correctly when we reopen stdin.
45
+ Process.setsid rescue Errno::EPERM
46
+
47
+ Expedite.app = self
48
+
49
+ Signal.trap("TERM") { terminate }
50
+
51
+ load_helper
52
+ eager_preload if false #if ENV.delete("SPRING_PRELOAD") == "1"
53
+ run
54
+ end
55
+
56
+ def state(val)
57
+ return if exiting?
58
+ log "#{@state} -> #{val}"
59
+ @state = val
60
+ end
61
+
62
+ def state!(val)
63
+ state val
64
+ @interrupt.last.write "."
65
+ end
66
+
67
+ def app_name
68
+ env.app_name
69
+ end
70
+
71
+ def log(message)
72
+ env.log "[application:#{variant}] #{message}"
73
+ end
74
+
75
+ def preloaded?
76
+ @preloaded
77
+ end
78
+
79
+ def preload_failed?
80
+ @preloaded == :failure
81
+ end
82
+
83
+ def exiting?
84
+ @state == :exiting
85
+ end
86
+
87
+ def terminating?
88
+ @state == :terminating
89
+ end
90
+
91
+ def initialized?
92
+ @state == :initialized
93
+ end
94
+
95
+ def preload
96
+ log "preloading app"
97
+
98
+ @preloaded = :success
99
+ rescue Exception => e
100
+ @preloaded = :failure
101
+ raise e unless initialized?
102
+ end
103
+
104
+ def eager_preload
105
+ with_pty { preload }
106
+ end
107
+
108
+ def run
109
+ $0 = "expedite variant | #{app_name} | #{variant}"
110
+
111
+ Expedite::Variants.lookup(variant).after_fork(variant)
112
+
113
+ state :running
114
+ manager.puts
115
+
116
+ loop do
117
+ IO.select [manager, @interrupt.first]
118
+
119
+ if terminating? || preload_failed?
120
+ exit
121
+ else
122
+ serve manager.recv_io(UNIXSocket)
123
+ end
124
+ end
125
+ end
126
+
127
+ def serve(client)
128
+ log "got client"
129
+ manager.puts
130
+
131
+ _stdout, stderr, _stdin = streams = 3.times.map { client.recv_io }
132
+ [STDOUT, STDERR, STDIN].zip(streams).each { |a, b| a.reopen(b) }
133
+
134
+ preload unless preloaded?
135
+
136
+ args, env = JSON.load(client.read(client.gets.to_i)).values_at("args", "env")
137
+
138
+ exec_name = args.shift
139
+ command = Expedite::Commands.lookup(exec_name)
140
+ command.setup(client)
141
+
142
+ connect_database
143
+
144
+ pid = fork {
145
+ Process.setsid
146
+ IGNORE_SIGNALS.each { |sig| trap(sig, "DEFAULT") }
147
+ trap("TERM", "DEFAULT")
148
+
149
+ ARGV.replace(args)
150
+ $0 = exec_name
151
+
152
+ # Delete all env vars which are unchanged from before Spring started
153
+ original_env.each { |k, v| ENV.delete k if ENV[k] == v }
154
+
155
+ # Load in the current env vars, except those which *were* changed when Spring started
156
+ env.each { |k, v| ENV[k] = v }
157
+
158
+ # requiring is faster, so if config.cache_classes was true in
159
+ # the environment's config file, then we can respect that from
160
+ # here on as we no longer need constant reloading.
161
+ if @original_cache_classes
162
+ ActiveSupport::Dependencies.mechanism = :require
163
+ Rails.application.config.cache_classes = true
164
+ end
165
+
166
+ connect_database
167
+ srand
168
+
169
+ invoke_after_fork_callbacks
170
+ shush_backtraces
171
+
172
+ command.call
173
+ }
174
+
175
+ disconnect_database
176
+
177
+ log "forked #{pid}"
178
+ manager.puts pid
179
+
180
+ wait pid, streams, client
181
+ rescue Exception => e
182
+ log "exception: #{e} at #{e.backtrace.join("\n")}"
183
+ manager.puts unless pid
184
+
185
+ if streams && !e.is_a?(SystemExit)
186
+ print_exception(stderr, e)
187
+ streams.each(&:close)
188
+ end
189
+
190
+ client.puts(1) if pid
191
+ client.close
192
+ ensure
193
+ # Redirect STDOUT and STDERR to prevent from keeping the original FDs
194
+ # (i.e. to prevent `spring rake -T | grep db` from hanging forever),
195
+ # even when exception is raised before forking (i.e. preloading).
196
+ reset_streams
197
+ end
198
+
199
+ def terminate
200
+ if exiting?
201
+ # Ensure that we do not ignore subsequent termination attempts
202
+ log "forced exit"
203
+ @waiting.each { |pid| Process.kill("TERM", pid) }
204
+ Kernel.exit
205
+ else
206
+ state! :terminating
207
+ end
208
+ end
209
+
210
+ def exit
211
+ state :exiting
212
+ manager.shutdown(:RDWR)
213
+ exit_if_finished
214
+ sleep
215
+ end
216
+
217
+ def exit_if_finished
218
+ @mutex.synchronize {
219
+ Kernel.exit if exiting? && @waiting.empty?
220
+ }
221
+ end
222
+
223
+ def invoke_after_fork_callbacks
224
+ # TODO:
225
+ end
226
+
227
+ def disconnect_database
228
+ ActiveRecord::Base.remove_connection if active_record_configured?
229
+ end
230
+
231
+ def connect_database
232
+ ActiveRecord::Base.establish_connection if active_record_configured?
233
+ end
234
+
235
+ # This feels very naughty
236
+ def shush_backtraces
237
+ Kernel.module_eval do
238
+ old_raise = Kernel.method(:raise)
239
+ remove_method :raise
240
+ define_method :raise do |*args|
241
+ begin
242
+ old_raise.call(*args)
243
+ ensure
244
+ if $!
245
+ lib = File.expand_path("..", __FILE__)
246
+ $!.backtrace.reject! { |line| line.start_with?(lib) }
247
+ end
248
+ end
249
+ end
250
+ private :raise
251
+ end
252
+ end
253
+
254
+ def print_exception(stream, error)
255
+ first, rest = error.backtrace.first, error.backtrace.drop(1)
256
+ stream.puts("#{first}: #{error} (#{error.class})")
257
+ rest.each { |line| stream.puts("\tfrom #{line}") }
258
+ end
259
+
260
+ def with_pty
261
+ PTY.open do |master, slave|
262
+ [STDOUT, STDERR, STDIN].each { |s| s.reopen slave }
263
+ reader_thread = Expedite.failsafe_thread { master.read }
264
+ begin
265
+ yield
266
+ ensure
267
+ reader_thread.kill
268
+ reset_streams
269
+ end
270
+ end
271
+ end
272
+
273
+ def reset_streams
274
+ [STDOUT, STDERR].each do |stream|
275
+ stream.reopen(env.log_file)
276
+ end
277
+ STDIN.reopen("/dev/null")
278
+ end
279
+
280
+ def wait(pid, streams, client)
281
+ @mutex.synchronize { @waiting << pid }
282
+
283
+ # Wait in a separate thread so we can run multiple commands at once
284
+ Expedite.failsafe_thread {
285
+ begin
286
+ _, status = Process.wait2 pid
287
+ log "#{pid} exited with #{status.exitstatus}"
288
+
289
+ streams.each(&:close)
290
+ client.puts(status.exitstatus)
291
+ client.close
292
+ ensure
293
+ @mutex.synchronize { @waiting.delete pid }
294
+ exit_if_finished
295
+ end
296
+ }
297
+
298
+ Expedite.failsafe_thread {
299
+ while signal = client.gets.chomp
300
+ begin
301
+ Process.kill(signal, -Process.getpgid(pid))
302
+ client.puts(0)
303
+ rescue Errno::ESRCH
304
+ client.puts(1)
305
+ end
306
+ end
307
+ }
308
+ end
309
+
310
+ private
311
+
312
+ def active_record_configured?
313
+ defined?(ActiveRecord::Base) && ActiveRecord::Base.configurations.any?
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,9 @@
1
+ require "expedite/application"
2
+
3
+ app = Expedite::Application.new(
4
+ ENV['EXPEDITE_VARIANT'],
5
+ UNIXSocket.for_fd(3),
6
+ {},
7
+ Expedite::Env.new(log_file: IO.for_fd(4))
8
+ )
9
+ app.boot
@@ -0,0 +1,180 @@
1
+ # Based on https://github.com/rails/spring/blob/master/lib/spring/application_manager.rb
2
+
3
+ require 'bundler'
4
+ require 'expedite/failsafe_thread'
5
+ require 'expedite/send_json'
6
+ require 'expedite/variants'
7
+
8
+ module Expedite
9
+ class ApplicationManager
10
+ include SendJson
11
+
12
+ attr_reader :pid, :child, :variant, :env, :status
13
+
14
+ def initialize(variant, env)
15
+ @variant = variant
16
+ @env = env
17
+ @mutex = Mutex.new
18
+ @state = :running
19
+ @pid = nil
20
+ end
21
+
22
+ def log(message)
23
+ env.log "[application_manager:#{variant}] #{message}"
24
+ end
25
+
26
+ # We're not using @mutex.synchronize to avoid the weird "<internal:prelude>:10"
27
+ # line which messes with backtraces in e.g. rspec
28
+ def synchronize
29
+ @mutex.lock
30
+ yield
31
+ ensure
32
+ @mutex.unlock
33
+ end
34
+
35
+ def start
36
+ start_child
37
+ end
38
+
39
+ def restart
40
+ return if @state == :stopping
41
+ start_child(true)
42
+ end
43
+
44
+ def alive?
45
+ @pid
46
+ end
47
+
48
+ def with_child
49
+ synchronize do
50
+ if alive?
51
+ begin
52
+ yield child
53
+ rescue Errno::ECONNRESET, Errno::EPIPE
54
+ # The child has died but has not been collected by the wait thread yet,
55
+ # so start a new child and try again.
56
+ log "child dead; starting"
57
+ start
58
+ yield child
59
+ end
60
+ else
61
+ log "child not running; starting"
62
+ start
63
+ yield child
64
+ end
65
+ end
66
+ end
67
+
68
+ # Returns the pid of the process running the command, or nil if the application process died.
69
+ def run(client)
70
+ @client = client
71
+ with_child do |child|
72
+ child.send_io client
73
+ child.gets or raise Errno::EPIPE
74
+ end
75
+
76
+ pid = child.gets.to_i
77
+
78
+ unless pid.zero?
79
+ log "got worker pid #{pid}"
80
+ pid
81
+ end
82
+ rescue Errno::ECONNRESET, Errno::EPIPE => e
83
+ log "#{e} while reading from child; returning no pid"
84
+ nil
85
+ ensure
86
+ client.close
87
+ end
88
+
89
+ def stop
90
+ log "stopping"
91
+ @state = :stopping
92
+
93
+ if pid
94
+ Process.kill('TERM', pid)
95
+ Process.wait(pid)
96
+ end
97
+ rescue Errno::ESRCH, Errno::ECHILD
98
+ # Don't care
99
+ end
100
+
101
+ def parent
102
+ Expedite::Variants.lookup(variant).parent
103
+ end
104
+
105
+ private
106
+
107
+ def start_child(preload = false)
108
+ if parent
109
+ fork_child(preload)
110
+ else
111
+ spawn_child(preload)
112
+ end
113
+ end
114
+
115
+ def fork_child(preload = false)
116
+ @child, child_socket = UNIXSocket.pair
117
+
118
+ # Compose command
119
+ wr, rd = UNIXSocket.pair
120
+ wr.send_io STDOUT
121
+ wr.send_io STDERR
122
+ wr.send_io STDIN
123
+
124
+ send_json wr, 'args' => ['expedite/boot', variant], 'env' => {}
125
+ wr.send_io child_socket
126
+ wr.send_io env.log_file
127
+ wr.close
128
+
129
+ @pid = env.applications[parent].run(rd)
130
+
131
+ start_wait_thread(pid, child) if child.gets
132
+ child_socket.close
133
+ end
134
+
135
+ def spawn_child(preload = false)
136
+ @child, child_socket = UNIXSocket.pair
137
+
138
+ Bundler.with_original_env do
139
+ bundler_dir = File.expand_path("../..", $LOADED_FEATURES.grep(/bundler\/setup\.rb$/).first)
140
+ @pid = Process.spawn(
141
+ {
142
+ "EXPEDITE_VARIANT" => variant,
143
+ },
144
+ "ruby",
145
+ *(bundler_dir != RbConfig::CONFIG["rubylibdir"] ? ["-I", bundler_dir] : []),
146
+ "-I", File.expand_path("../..", __FILE__),
147
+ "-e", "require 'expedite/application/boot'",
148
+ 3 => child_socket,
149
+ 4 => env.log_file,
150
+ )
151
+ end
152
+
153
+ start_wait_thread(pid, child) if child.gets
154
+ child_socket.close
155
+ end
156
+
157
+ def start_wait_thread(pid, child)
158
+ Process.detach(pid)
159
+
160
+ Expedite.failsafe_thread do
161
+ # The recv can raise an ECONNRESET, killing the thread, but that's ok
162
+ # as if it does we're no longer interested in the child
163
+ loop do
164
+ IO.select([child])
165
+ break if child.recv(1, Socket::MSG_PEEK).empty?
166
+ sleep 0.01
167
+ end
168
+
169
+ log "child #{pid} shutdown"
170
+
171
+ synchronize {
172
+ if @pid == pid
173
+ @pid = nil
174
+ restart
175
+ end
176
+ }
177
+ end
178
+ end
179
+ end
180
+ end