spring_standalone 0.1.13

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +418 -0
  4. data/bin/spring_sa +49 -0
  5. data/lib/spring_standalone/application.rb +367 -0
  6. data/lib/spring_standalone/application/boot.rb +19 -0
  7. data/lib/spring_standalone/application_manager.rb +141 -0
  8. data/lib/spring_standalone/binstub.rb +13 -0
  9. data/lib/spring_standalone/boot.rb +10 -0
  10. data/lib/spring_standalone/client.rb +48 -0
  11. data/lib/spring_standalone/client/binstub.rb +198 -0
  12. data/lib/spring_standalone/client/command.rb +18 -0
  13. data/lib/spring_standalone/client/help.rb +62 -0
  14. data/lib/spring_standalone/client/rails.rb +34 -0
  15. data/lib/spring_standalone/client/run.rb +232 -0
  16. data/lib/spring_standalone/client/server.rb +18 -0
  17. data/lib/spring_standalone/client/status.rb +30 -0
  18. data/lib/spring_standalone/client/stop.rb +22 -0
  19. data/lib/spring_standalone/client/version.rb +11 -0
  20. data/lib/spring_standalone/command_wrapper.rb +82 -0
  21. data/lib/spring_standalone/commands.rb +50 -0
  22. data/lib/spring_standalone/commands/rake.rb +30 -0
  23. data/lib/spring_standalone/configuration.rb +58 -0
  24. data/lib/spring_standalone/env.rb +116 -0
  25. data/lib/spring_standalone/errors.rb +36 -0
  26. data/lib/spring_standalone/failsafe_thread.rb +14 -0
  27. data/lib/spring_standalone/json.rb +626 -0
  28. data/lib/spring_standalone/process_title_updater.rb +65 -0
  29. data/lib/spring_standalone/server.rb +150 -0
  30. data/lib/spring_standalone/sid.rb +42 -0
  31. data/lib/spring_standalone/version.rb +3 -0
  32. data/lib/spring_standalone/watcher.rb +30 -0
  33. data/lib/spring_standalone/watcher/abstract.rb +117 -0
  34. data/lib/spring_standalone/watcher/polling.rb +98 -0
  35. metadata +106 -0
@@ -0,0 +1,367 @@
1
+ require "spring_standalone/boot"
2
+ require "set"
3
+ require "pty"
4
+
5
+ module SpringStandalone
6
+ class Application
7
+ attr_reader :manager, :watcher, :spring_env, :original_env
8
+
9
+ def initialize(manager, original_env, spring_env = Env.new)
10
+ @manager = manager
11
+ @original_env = original_env
12
+ @spring_env = spring_env
13
+ @mutex = Mutex.new
14
+ @waiting = Set.new
15
+ @preloaded = false
16
+ @state = :initialized
17
+ @interrupt = IO.pipe
18
+ end
19
+
20
+ def state(val)
21
+ return if exiting?
22
+ log "#{@state} -> #{val}"
23
+ @state = val
24
+ end
25
+
26
+ def state!(val)
27
+ state val
28
+ @interrupt.last.write "."
29
+ end
30
+
31
+ def app_env
32
+ ENV['APP_ENV']
33
+ end
34
+
35
+ def app_name
36
+ spring_env.app_name
37
+ end
38
+
39
+ def log(message)
40
+ spring_env.log "[application:#{app_env}] #{message}"
41
+ end
42
+
43
+ def preloaded?
44
+ @preloaded
45
+ end
46
+
47
+ def preload_failed?
48
+ @preloaded == :failure
49
+ end
50
+
51
+ def exiting?
52
+ @state == :exiting
53
+ end
54
+
55
+ def terminating?
56
+ @state == :terminating
57
+ end
58
+
59
+ def watcher_stale?
60
+ @state == :watcher_stale
61
+ end
62
+
63
+ def initialized?
64
+ @state == :initialized
65
+ end
66
+
67
+ def start_watcher
68
+ @watcher = SpringStandalone.watcher
69
+
70
+ @watcher.on_stale do
71
+ state! :watcher_stale
72
+ end
73
+
74
+ if @watcher.respond_to? :on_debug
75
+ @watcher.on_debug do |message|
76
+ spring_env.log "[watcher:#{app_env}] #{message}"
77
+ end
78
+ end
79
+
80
+ @watcher.start
81
+ end
82
+
83
+ def preload
84
+ log "preloading app"
85
+
86
+ begin
87
+ require "spring_standalone/commands"
88
+ ensure
89
+ start_watcher
90
+ end
91
+
92
+ #require SpringStandalone.application_root_path.join("config", "application")
93
+
94
+ # unless Rails.respond_to?(:gem_version) && Rails.gem_version >= Gem::Version.new('5.2.0')
95
+ # raise "SpringStandalone only supports Rails >= 5.2.0"
96
+ # end
97
+
98
+ # # config/environments/test.rb will have config.cache_classes = true. However
99
+ # # we want it to be false so that we can reload files. This is a hack to
100
+ # # override the effect of config.cache_classes = true. We can then actually
101
+ # # set config.cache_classes = false after loading the environment.
102
+ # Rails::Application.initializer :initialize_dependency_mechanism, group: :all do
103
+ # ActiveSupport::Dependencies.mechanism = :load
104
+ # end
105
+
106
+ #require SpringStandalone.application_root_path.join("config", "environment")
107
+
108
+ # @original_cache_classes = Rails.application.config.cache_classes
109
+ # Rails.application.config.cache_classes = false
110
+
111
+ disconnect_database
112
+
113
+ @preloaded = :success
114
+ rescue Exception => e
115
+ @preloaded = :failure
116
+ watcher.add e.backtrace.map { |line| line[/^(.*)\:\d+/, 1] }
117
+ raise e unless initialized?
118
+ ensure
119
+ watcher.add loaded_application_features
120
+ watcher.add SpringStandalone.gemfile, "#{SpringStandalone.gemfile}.lock"
121
+
122
+ # if defined?(Rails) && Rails.application
123
+ # watcher.add Rails.application.paths["config/initializers"]
124
+ # watcher.add Rails.application.paths["config/database"]
125
+ # if secrets_path = Rails.application.paths["config/secrets"]
126
+ # watcher.add secrets_path
127
+ # end
128
+ # end
129
+ end
130
+
131
+ def eager_preload
132
+ with_pty { preload }
133
+ end
134
+
135
+ def run
136
+ state :running
137
+ manager.puts
138
+
139
+ loop do
140
+ IO.select [manager, @interrupt.first]
141
+
142
+ if terminating? || watcher_stale? || preload_failed?
143
+ exit
144
+ else
145
+ serve manager.recv_io(UNIXSocket)
146
+ end
147
+ end
148
+ end
149
+
150
+ def serve(client)
151
+ log "got client"
152
+ manager.puts
153
+
154
+ _stdout, stderr, _stdin = streams = 3.times.map { client.recv_io }
155
+ [STDOUT, STDERR, STDIN].zip(streams).each { |a, b| a.reopen(b) }
156
+
157
+ preload unless preloaded?
158
+
159
+ args, env = JSON.load(client.read(client.gets.to_i)).values_at("args", "env")
160
+ command = SpringStandalone.command(args.shift)
161
+
162
+ connect_database
163
+ setup command
164
+
165
+ # if Rails.application.reloaders.any?(&:updated?)
166
+ # Rails.application.reloader.reload!
167
+ # end
168
+
169
+ pid = fork {
170
+ Process.setsid
171
+ IGNORE_SIGNALS.each { |sig| trap(sig, "DEFAULT") }
172
+ trap("TERM", "DEFAULT")
173
+
174
+ unless SpringStandalone.quiet
175
+ STDERR.puts "Running via SpringStandalone preloader in process #{Process.pid}"
176
+
177
+ # if Rails.env.production?
178
+ # STDERR.puts "WARNING: SpringStandalone is running in production. To fix " \
179
+ # "this make sure the spring gem is only present " \
180
+ # "in `development` and `test` groups in your Gemfile " \
181
+ # "and make sure you always use " \
182
+ # "`bundle install --without development test` in production"
183
+ # end
184
+ end
185
+
186
+ ARGV.replace(args)
187
+ $0 = command.exec_name
188
+
189
+ # Delete all env vars which are unchanged from before SpringStandalone started
190
+ original_env.each { |k, v| ENV.delete k if ENV[k] == v }
191
+
192
+ # Load in the current env vars, except those which *were* changed when SpringStandalone started
193
+ env.each { |k, v| ENV[k] ||= v }
194
+
195
+ # # requiring is faster, so if config.cache_classes was true in
196
+ # # the environment's config file, then we can respect that from
197
+ # # here on as we no longer need constant reloading.
198
+ # if @original_cache_classes
199
+ # ActiveSupport::Dependencies.mechanism = :require
200
+ # Rails.application.config.cache_classes = true
201
+ # end
202
+
203
+ connect_database
204
+ srand
205
+
206
+ invoke_after_fork_callbacks
207
+ shush_backtraces
208
+
209
+ command.call
210
+ }
211
+
212
+ disconnect_database
213
+
214
+ log "forked #{pid}"
215
+ manager.puts pid
216
+
217
+ wait pid, streams, client
218
+ rescue Exception => e
219
+ log "exception: #{e}"
220
+ manager.puts unless pid
221
+
222
+ if streams && !e.is_a?(SystemExit)
223
+ print_exception(stderr, e)
224
+ streams.each(&:close)
225
+ end
226
+
227
+ client.puts(1) if pid
228
+ client.close
229
+ ensure
230
+ # Redirect STDOUT and STDERR to prevent from keeping the original FDs
231
+ # (i.e. to prevent `spring rake -T | grep db` from hanging forever),
232
+ # even when exception is raised before forking (i.e. preloading).
233
+ reset_streams
234
+ end
235
+
236
+ def terminate
237
+ if exiting?
238
+ # Ensure that we do not ignore subsequent termination attempts
239
+ log "forced exit"
240
+ @waiting.each { |pid| Process.kill("TERM", pid) }
241
+ Kernel.exit
242
+ else
243
+ state! :terminating
244
+ end
245
+ end
246
+
247
+ def exit
248
+ state :exiting
249
+ manager.shutdown(:RDWR)
250
+ exit_if_finished
251
+ sleep
252
+ end
253
+
254
+ def exit_if_finished
255
+ @mutex.synchronize {
256
+ Kernel.exit if exiting? && @waiting.empty?
257
+ }
258
+ end
259
+
260
+ # The command might need to require some files in the
261
+ # main process so that they are cached. For example a test command wants to
262
+ # load the helper file once and have it cached.
263
+ def setup(command)
264
+ if command.setup
265
+ watcher.add loaded_application_features # loaded features may have changed
266
+ end
267
+ end
268
+
269
+ def invoke_after_fork_callbacks
270
+ SpringStandalone.after_fork_callbacks.each do |callback|
271
+ callback.call
272
+ end
273
+ end
274
+
275
+ def loaded_application_features
276
+ root = SpringStandalone.application_root_path.to_s
277
+ $LOADED_FEATURES.select { |f| f.start_with?(root) }
278
+ end
279
+
280
+ def disconnect_database
281
+ #ActiveRecord::Base.remove_connection if active_record_configured?
282
+ end
283
+
284
+ def connect_database
285
+ #ActiveRecord::Base.establish_connection if active_record_configured?
286
+ end
287
+
288
+ # This feels very naughty
289
+ def shush_backtraces
290
+ Kernel.module_eval do
291
+ old_raise = Kernel.method(:raise)
292
+ remove_method :raise
293
+ define_method :raise do |*args|
294
+ begin
295
+ old_raise.call(*args)
296
+ ensure
297
+ if $!
298
+ lib = File.expand_path("..", __FILE__)
299
+ $!.backtrace.reject! { |line| line.start_with?(lib) }
300
+ end
301
+ end
302
+ end
303
+ private :raise
304
+ end
305
+ end
306
+
307
+ def print_exception(stream, error)
308
+ first, rest = error.backtrace.first, error.backtrace.drop(1)
309
+ stream.puts("#{first}: #{error} (#{error.class})")
310
+ rest.each { |line| stream.puts("\tfrom #{line}") }
311
+ end
312
+
313
+ def with_pty
314
+ PTY.open do |master, slave|
315
+ [STDOUT, STDERR, STDIN].each { |s| s.reopen slave }
316
+ reader_thread = SpringStandalone.failsafe_thread { master.read }
317
+ begin
318
+ yield
319
+ ensure
320
+ reader_thread.kill
321
+ reset_streams
322
+ end
323
+ end
324
+ end
325
+
326
+ def reset_streams
327
+ [STDOUT, STDERR].each { |stream| stream.reopen(spring_env.log_file) }
328
+ STDIN.reopen("/dev/null")
329
+ end
330
+
331
+ def wait(pid, streams, client)
332
+ @mutex.synchronize { @waiting << pid }
333
+
334
+ # Wait in a separate thread so we can run multiple commands at once
335
+ SpringStandalone.failsafe_thread {
336
+ begin
337
+ _, status = Process.wait2 pid
338
+ log "#{pid} exited with #{status.exitstatus}"
339
+
340
+ streams.each(&:close)
341
+ client.puts(status.exitstatus)
342
+ client.close
343
+ ensure
344
+ @mutex.synchronize { @waiting.delete pid }
345
+ exit_if_finished
346
+ end
347
+ }
348
+
349
+ SpringStandalone.failsafe_thread {
350
+ while signal = client.gets.chomp
351
+ begin
352
+ Process.kill(signal, -Process.getpgid(pid))
353
+ client.puts(0)
354
+ rescue Errno::ESRCH
355
+ client.puts(1)
356
+ end
357
+ end
358
+ }
359
+ end
360
+
361
+ private
362
+
363
+ # def active_record_configured?
364
+ # defined?(ActiveRecord::Base) && ActiveRecord::Base.configurations.any?
365
+ # end
366
+ end
367
+ end
@@ -0,0 +1,19 @@
1
+ # This is necessary for the terminal to work correctly when we reopen stdin.
2
+ Process.setsid
3
+
4
+ require "spring_standalone/application"
5
+
6
+ app = SpringStandalone::Application.new(
7
+ UNIXSocket.for_fd(3),
8
+ SpringStandalone::JSON.load(ENV.delete("SPRING_ORIGINAL_ENV").dup),
9
+ SpringStandalone::Env.new(log_file: IO.for_fd(4))
10
+ )
11
+
12
+ Signal.trap("TERM") { app.terminate }
13
+
14
+ SpringStandalone::ProcessTitleUpdater.run { |distance|
15
+ "spring standalone app | #{app.app_name} | started #{distance} ago | #{app.app_env} mode"
16
+ }
17
+
18
+ app.eager_preload if ENV.delete("SPRING_PRELOAD") == "1"
19
+ app.run
@@ -0,0 +1,141 @@
1
+ module SpringStandalone
2
+ class ApplicationManager
3
+ attr_reader :pid, :child, :app_env, :spring_env, :status
4
+
5
+ def initialize(app_env, spring_env)
6
+ @app_env = app_env
7
+ @spring_env = spring_env
8
+ @mutex = Mutex.new
9
+ @state = :running
10
+ @pid = nil
11
+ end
12
+
13
+ def log(message)
14
+ spring_env.log "[application_manager:#{app_env}] #{message}"
15
+ end
16
+
17
+ # We're not using @mutex.synchronize to avoid the weird "<internal:prelude>:10"
18
+ # line which messes with backtraces in e.g. rspec
19
+ def synchronize
20
+ @mutex.lock
21
+ yield
22
+ ensure
23
+ @mutex.unlock
24
+ end
25
+
26
+ def start
27
+ start_child
28
+ end
29
+
30
+ def restart
31
+ return if @state == :stopping
32
+ start_child(true)
33
+ end
34
+
35
+ def alive?
36
+ @pid
37
+ end
38
+
39
+ def with_child
40
+ synchronize do
41
+ if alive?
42
+ begin
43
+ yield
44
+ rescue Errno::ECONNRESET, Errno::EPIPE
45
+ # The child has died but has not been collected by the wait thread yet,
46
+ # so start a new child and try again.
47
+ log "child dead; starting"
48
+ start
49
+ yield
50
+ end
51
+ else
52
+ log "child not running; starting"
53
+ start
54
+ yield
55
+ end
56
+ end
57
+ end
58
+
59
+ # Returns the pid of the process running the command, or nil if the application process died.
60
+ def run(client)
61
+ with_child do
62
+ child.send_io client
63
+ child.gets or raise Errno::EPIPE
64
+ end
65
+
66
+ pid = child.gets.to_i
67
+
68
+ unless pid.zero?
69
+ log "got worker pid #{pid}"
70
+ pid
71
+ end
72
+ rescue Errno::ECONNRESET, Errno::EPIPE => e
73
+ log "#{e} while reading from child; returning no pid"
74
+ nil
75
+ ensure
76
+ client.close
77
+ end
78
+
79
+ def stop
80
+ log "stopping"
81
+ @state = :stopping
82
+
83
+ if pid
84
+ Process.kill('TERM', pid)
85
+ Process.wait(pid)
86
+ end
87
+ rescue Errno::ESRCH, Errno::ECHILD
88
+ # Don't care
89
+ end
90
+
91
+ private
92
+
93
+ def start_child(preload = false)
94
+ @child, child_socket = UNIXSocket.pair
95
+
96
+ Bundler.with_original_env do
97
+ bundler_dir = File.expand_path("../..", $LOADED_FEATURES.grep(/bundler\/setup\.rb$/).first)
98
+ @pid = Process.spawn(
99
+ {
100
+ "APP_ENV" => app_env,
101
+ "RACK_ENV" => app_env,
102
+ "SPRING_ORIGINAL_ENV" => JSON.dump(SpringStandalone::ORIGINAL_ENV),
103
+ "SPRING_PRELOAD" => preload ? "1" : "0"
104
+ },
105
+ "ruby",
106
+ *(bundler_dir != RbConfig::CONFIG["rubylibdir"] ? ["-I", bundler_dir] : []),
107
+ "-I", File.expand_path("../..", __FILE__),
108
+ "-e", "require 'spring_standalone/application/boot'",
109
+ 3 => child_socket,
110
+ 4 => spring_env.log_file,
111
+ )
112
+ end
113
+
114
+ start_wait_thread(pid, child) if child.gets
115
+ child_socket.close
116
+ end
117
+
118
+ def start_wait_thread(pid, child)
119
+ Process.detach(pid)
120
+
121
+ SpringStandalone.failsafe_thread {
122
+ # The recv can raise an ECONNRESET, killing the thread, but that's ok
123
+ # as if it does we're no longer interested in the child
124
+ loop do
125
+ IO.select([child])
126
+ break if child.recv(1, Socket::MSG_PEEK).empty?
127
+ sleep 0.01
128
+ end
129
+
130
+ log "child #{pid} shutdown"
131
+
132
+ synchronize {
133
+ if @pid == pid
134
+ @pid = nil
135
+ restart
136
+ end
137
+ }
138
+ }
139
+ end
140
+ end
141
+ end