spring_standalone 0.1.13

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.
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