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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +418 -0
- data/bin/spring_sa +49 -0
- data/lib/spring_standalone/application.rb +367 -0
- data/lib/spring_standalone/application/boot.rb +19 -0
- data/lib/spring_standalone/application_manager.rb +141 -0
- data/lib/spring_standalone/binstub.rb +13 -0
- data/lib/spring_standalone/boot.rb +10 -0
- data/lib/spring_standalone/client.rb +48 -0
- data/lib/spring_standalone/client/binstub.rb +198 -0
- data/lib/spring_standalone/client/command.rb +18 -0
- data/lib/spring_standalone/client/help.rb +62 -0
- data/lib/spring_standalone/client/rails.rb +34 -0
- data/lib/spring_standalone/client/run.rb +232 -0
- data/lib/spring_standalone/client/server.rb +18 -0
- data/lib/spring_standalone/client/status.rb +30 -0
- data/lib/spring_standalone/client/stop.rb +22 -0
- data/lib/spring_standalone/client/version.rb +11 -0
- data/lib/spring_standalone/command_wrapper.rb +82 -0
- data/lib/spring_standalone/commands.rb +50 -0
- data/lib/spring_standalone/commands/rake.rb +30 -0
- data/lib/spring_standalone/configuration.rb +58 -0
- data/lib/spring_standalone/env.rb +116 -0
- data/lib/spring_standalone/errors.rb +36 -0
- data/lib/spring_standalone/failsafe_thread.rb +14 -0
- data/lib/spring_standalone/json.rb +626 -0
- data/lib/spring_standalone/process_title_updater.rb +65 -0
- data/lib/spring_standalone/server.rb +150 -0
- data/lib/spring_standalone/sid.rb +42 -0
- data/lib/spring_standalone/version.rb +3 -0
- data/lib/spring_standalone/watcher.rb +30 -0
- data/lib/spring_standalone/watcher/abstract.rb +117 -0
- data/lib/spring_standalone/watcher/polling.rb +98 -0
- 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
|