toys-core 0.10.4 → 0.11.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -250,13 +250,14 @@ module Toys
250
250
  exec_opts = Opts.new(@default_opts).add(opts)
251
251
  spawn_cmd =
252
252
  if cmd.is_a?(::Array)
253
- if cmd.size == 1 && cmd.first.is_a?(::String)
254
- [[cmd.first, exec_opts.config_opts[:argv0] || cmd.first]]
253
+ if cmd.size > 1
254
+ binary = canonical_binary_spec(cmd.first, exec_opts)
255
+ [binary] + cmd[1..-1].map(&:to_s)
255
256
  else
256
- cmd
257
+ [canonical_binary_spec(Array(cmd.first), exec_opts)]
257
258
  end
258
259
  else
259
- [cmd]
260
+ [cmd.to_s]
260
261
  end
261
262
  executor = Executor.new(exec_opts, spawn_cmd, block)
262
263
  executor.execute
@@ -492,7 +493,8 @@ module Toys
492
493
  #
493
494
  class Controller
494
495
  ## @private
495
- def initialize(name, controller_streams, captures, pid, join_threads, result_callback)
496
+ def initialize(name, controller_streams, captures, pid, join_threads,
497
+ result_callback, mutex)
496
498
  @name = name
497
499
  @in = controller_streams[:in]
498
500
  @out = controller_streams[:out]
@@ -508,6 +510,7 @@ module Toys
508
510
  end
509
511
  @join_threads = join_threads
510
512
  @result_callback = result_callback
513
+ @mutex = mutex
511
514
  @result = nil
512
515
  end
513
516
 
@@ -547,7 +550,7 @@ module Toys
547
550
  ##
548
551
  # The process ID.
549
552
  #
550
- # Exactly one of `exception` and `pid` will be non-nil.
553
+ # Exactly one of {#exception} and {#pid} will be non-nil.
551
554
  #
552
555
  # @return [Integer] if the process start was successful
553
556
  # @return [nil] if the process could not be started.
@@ -557,7 +560,7 @@ module Toys
557
560
  ##
558
561
  # The exception raised when the process failed to start.
559
562
  #
560
- # Exactly one of `exception` and `pid` will be non-nil.
563
+ # Exactly one of {#exception} and {#pid} will be non-nil.
561
564
  #
562
565
  # @return [Exception] if the process failed to start.
563
566
  # @return [nil] if the process start was successful.
@@ -575,7 +578,10 @@ module Toys
575
578
  stream = stream_for(which)
576
579
  @join_threads << ::Thread.new do
577
580
  begin
578
- @captures[which] = stream.read
581
+ data = stream.read
582
+ @mutex.synchronize do
583
+ @captures[which] = data
584
+ end
579
585
  ensure
580
586
  stream.close
581
587
  end
@@ -722,15 +728,20 @@ module Toys
722
728
  ##
723
729
  # Wait for the subcommand to complete, and return a result object.
724
730
  #
731
+ # Closes the control streams if present. The stdin stream is always
732
+ # closed, even if the call times out. The stdout and stderr streams are
733
+ # closed only after the command terminates.
734
+ #
725
735
  # @param timeout [Numeric,nil] The timeout in seconds, or `nil` to
726
736
  # wait indefinitely.
727
737
  # @return [Toys::Utils::Exec::Result] The result object
728
738
  # @return [nil] if a timeout occurred.
729
739
  #
730
740
  def result(timeout: nil)
741
+ close_streams(:in)
731
742
  return nil if @wait_thread && !@wait_thread.join(timeout)
732
743
  @result ||= begin
733
- close_streams
744
+ close_streams(:out)
734
745
  @join_threads.each(&:join)
735
746
  Result.new(name, @captures[:out], @captures[:err], @wait_thread&.value, @exception)
736
747
  .tap { |result| @result_callback&.call(result) }
@@ -738,13 +749,13 @@ module Toys
738
749
  end
739
750
 
740
751
  ##
741
- # Close all the controller's streams.
752
+ # Close the controller's streams.
742
753
  # @private
743
754
  #
744
- def close_streams
745
- @in.close if @in && !@in.closed?
746
- @out.close if @out && !@out.closed?
747
- @err.close if @err && !@err.closed?
755
+ def close_streams(which)
756
+ @in.close if which != :out && @in && !@in.closed?
757
+ @out.close if which != :in && @out && !@out.closed?
758
+ @err.close if which != :in && @err && !@err.closed?
748
759
  self
749
760
  end
750
761
 
@@ -773,7 +784,21 @@ module Toys
773
784
  end
774
785
 
775
786
  ##
776
- # The result returned from a subcommand execution.
787
+ # The result returned from a subcommand execution. This includes the
788
+ # identifying name of the execution (if any), the result status of the
789
+ # execution, and any captured stream output.
790
+ #
791
+ # Possible result statuses are:
792
+ #
793
+ # * The process failed to start. {Result#failed?} will return true, and
794
+ # {Result#exception} will return an exception describing the failure
795
+ # (often an errno).
796
+ # * The process executed and exited with a normal exit code. Either
797
+ # {Result#success?} or {Result#error?} will return true, and
798
+ # {Result.exit_code} will return the numeric exit code.
799
+ # * The process executed but was terminated by an uncaught signal.
800
+ # {Result#signaled?} will return true, and {Result#term_signal} will
801
+ # return the numeric signal code.
777
802
  #
778
803
  class Result
779
804
  ## @private
@@ -809,11 +834,13 @@ module Toys
809
834
  attr_reader :captured_err
810
835
 
811
836
  ##
812
- # The status code object.
837
+ # The Ruby process status object, providing various information about
838
+ # the ending state of the process.
813
839
  #
814
- # Exactly one of `exception` and `status` will be non-nil.
840
+ # Exactly one of {#exception} and {#status} will be non-nil.
815
841
  #
816
- # @return [Process::Status] The status code.
842
+ # @return [Process::Status] The status, if the process was successfully
843
+ # spanwed and terminated.
817
844
  # @return [nil] if the process could not be started.
818
845
  #
819
846
  attr_reader :status
@@ -821,7 +848,7 @@ module Toys
821
848
  ##
822
849
  # The exception raised if a process couldn't be started.
823
850
  #
824
- # Exactly one of `exception` and `status` will be non-nil.
851
+ # Exactly one of {#exception} and {#status} will be non-nil.
825
852
  #
826
853
  # @return [Exception] The exception raised from process start.
827
854
  # @return [nil] if the process started successfully.
@@ -829,33 +856,76 @@ module Toys
829
856
  attr_reader :exception
830
857
 
831
858
  ##
832
- # The numeric status code.
859
+ # The numeric status code for a process that exited normally,
833
860
  #
834
- # This will be a nonzero integer if the process failed to start. That
835
- # is, `exit_code` will never be `nil`, even if `status` is `nil`.
861
+ # Exactly one of {#exception}, {#exit_code}, and {#term_signal} will be
862
+ # non-nil.
836
863
  #
837
- # @return [Integer]
864
+ # @return [Integer] the numeric status code, if the process started
865
+ # successfully and exited normally.
866
+ # @return [nil] if the process did not start successfully, or was
867
+ # terminated by an uncaught signal.
838
868
  #
839
869
  def exit_code
840
- status ? status.exitstatus : 127
870
+ status&.exitstatus
871
+ end
872
+
873
+ ##
874
+ # The numeric signal code that caused process termination.
875
+ #
876
+ # Exactly one of {#exception}, {#exit_code}, and {#term_signal} will be
877
+ # non-nil.
878
+ #
879
+ # @return [Integer] The signal that caused the process to terminate.
880
+ # @return [nil] if the process did not start successfully, or executed
881
+ # and exited with a normal exit code.
882
+ #
883
+ def term_signal
884
+ status&.termsig
885
+ end
886
+
887
+ ##
888
+ # Returns true if the subprocess failed to start, or false if the
889
+ # process was able to execute.
890
+ #
891
+ # @return [Boolean]
892
+ #
893
+ def failed?
894
+ status.nil?
895
+ end
896
+
897
+ ##
898
+ # Returns true if the subprocess terminated due to an unhandled signal,
899
+ # or false if the process failed to start or exited normally.
900
+ #
901
+ # @return [Boolean]
902
+ #
903
+ def signaled?
904
+ !term_signal.nil?
841
905
  end
842
906
 
843
907
  ##
844
- # Returns true if the subprocess terminated with a zero status.
908
+ # Returns true if the subprocess terminated with a zero status, or
909
+ # false if the process failed to start, terminated due to a signal, or
910
+ # returned a nonzero status.
845
911
  #
846
912
  # @return [Boolean]
847
913
  #
848
914
  def success?
849
- exit_code.zero?
915
+ code = exit_code
916
+ !code.nil? && code.zero?
850
917
  end
851
918
 
852
919
  ##
853
- # Returns true if the subprocess terminated with a nonzero status.
920
+ # Returns true if the subprocess terminated with a nonzero status, or
921
+ # false if the process failed to start, terminated due to a signal, or
922
+ # returned a zero status.
854
923
  #
855
924
  # @return [Boolean]
856
925
  #
857
926
  def error?
858
- !exit_code.zero?
927
+ code = exit_code
928
+ !code.nil? && !code.zero?
859
929
  end
860
930
  end
861
931
 
@@ -876,6 +946,7 @@ module Toys
876
946
  @parent_streams = []
877
947
  @block = block
878
948
  @default_stream = @config_opts[:background] ? :null : :inherit
949
+ @mutex = ::Mutex.new
879
950
  end
880
951
 
881
952
  def execute
@@ -887,10 +958,10 @@ module Toys
887
958
  return controller if @config_opts[:background]
888
959
  begin
889
960
  @block&.call(controller)
961
+ controller.result
890
962
  ensure
891
- controller.close_streams
963
+ controller.close_streams(:both)
892
964
  end
893
- controller.result
894
965
  end
895
966
 
896
967
  private
@@ -898,12 +969,19 @@ module Toys
898
969
  def log_command
899
970
  logger = @config_opts[:logger]
900
971
  if logger && @config_opts[:log_level] != false
901
- cmd_str = @config_opts[:log_cmd]
902
- cmd_str ||= @spawn_cmd.size == 1 ? @spawn_cmd.first : @spawn_cmd.inspect if @spawn_cmd
972
+ cmd_str = @config_opts[:log_cmd] || default_log_str(@spawn_cmd)
903
973
  logger.add(@config_opts[:log_level] || ::Logger::INFO, cmd_str) if cmd_str
904
974
  end
905
975
  end
906
976
 
977
+ def default_log_str(spawn_cmd)
978
+ return nil unless spawn_cmd
979
+ return spawn_cmd.first if spawn_cmd.size == 1 && spawn_cmd.first.is_a?(::String)
980
+ cmd_binary = spawn_cmd.first
981
+ cmd_binary = cmd_binary.first if cmd_binary.is_a?(::Array)
982
+ ([cmd_binary] + spawn_cmd[1..-1]).inspect
983
+ end
984
+
907
985
  def start_with_controller
908
986
  pid =
909
987
  begin
@@ -913,7 +991,7 @@ module Toys
913
991
  end
914
992
  @child_streams.each(&:close)
915
993
  Controller.new(@config_opts[:name], @controller_streams, @captures, pid,
916
- @join_threads, @config_opts[:result_callback])
994
+ @join_threads, @config_opts[:result_callback], @mutex)
917
995
  end
918
996
 
919
997
  def start_process
@@ -1220,13 +1298,27 @@ module Toys
1220
1298
  stream = make_out_pipe(key)
1221
1299
  @join_threads << ::Thread.new do
1222
1300
  begin
1223
- @captures[key] = stream.read
1301
+ data = stream.read
1302
+ @mutex.synchronize do
1303
+ @captures[key] = data
1304
+ end
1224
1305
  ensure
1225
1306
  stream.close
1226
1307
  end
1227
1308
  end
1228
1309
  end
1229
1310
  end
1311
+
1312
+ private
1313
+
1314
+ def canonical_binary_spec(cmd, exec_opts)
1315
+ config_argv0 = exec_opts.config_opts[:argv0]
1316
+ return cmd.to_s if !config_argv0 && !cmd.is_a?(::Array)
1317
+ cmd = Array(cmd)
1318
+ actual_cmd = cmd.first
1319
+ argv0 = cmd[1] || config_argv0 || actual_cmd
1320
+ [actual_cmd.to_s, argv0.to_s]
1321
+ end
1230
1322
  end
1231
1323
  end
1232
1324
  end
@@ -64,6 +64,19 @@ module Toys
64
64
  class AlreadyBundledError < BundlerFailedError
65
65
  end
66
66
 
67
+ ##
68
+ # The bundle contained a toys or toys-core dependency that is
69
+ # incompatible with the currently running version.
70
+ #
71
+ class IncompatibleToysError < BundlerFailedError
72
+ end
73
+
74
+ ##
75
+ # The gemfile names that are searched by default.
76
+ # @return [Array<String>]
77
+ #
78
+ DEFAULT_GEMFILE_NAMES = [".gems.rb", "gems.rb", "Gemfile"].freeze
79
+
67
80
  ##
68
81
  # Activate the given gem. If it is not present, attempt to install it (or
69
82
  # inform the user to update the bundle).
@@ -142,16 +155,33 @@ module Toys
142
155
  end
143
156
 
144
157
  ##
145
- # Set up the bundle.
158
+ # Search for an appropriate Gemfile, and set up the bundle.
159
+ #
160
+ # @param groups [Array<String>] The groups to include in setup.
161
+ #
162
+ # @param gemfile_path [String] The path to the Gemfile to use. If `nil`
163
+ # or not given, the `:search_dirs` will be searched for a Gemfile.
164
+ #
165
+ # @param search_dirs [String,Array<String>] Directories in which to
166
+ # search for a Gemfile, if gemfile_path is not given. You can provide
167
+ # a single directory or an array of directories.
168
+ #
169
+ # @param gemfile_names [String,Array<String>] File names that are
170
+ # recognized as Gemfiles, when searching because gemfile_path is not
171
+ # given. Defaults to {DEFAULT_GEMFILE_NAMES}.
146
172
  #
147
- # @param groups [Array<String>] The groups to include in setup
148
- # @param search_dirs [Array<String>] Directories to search for a Gemfile
149
173
  # @return [void]
150
174
  #
151
175
  def bundle(groups: nil,
152
- search_dirs: nil)
176
+ gemfile_path: nil,
177
+ search_dirs: nil,
178
+ gemfile_names: nil)
179
+ Array(search_dirs).each do |dir|
180
+ break if gemfile_path
181
+ gemfile_path = Gems.find_gemfile(dir, gemfile_names: gemfile_names)
182
+ end
183
+ raise GemfileNotFoundError, "Gemfile not found" unless gemfile_path
153
184
  Gems.synchronize do
154
- gemfile_path = find_gemfile(Array(search_dirs))
155
185
  if configure_gemfile(gemfile_path)
156
186
  activate("bundler", "~> 2.1")
157
187
  require "bundler"
@@ -160,9 +190,19 @@ module Toys
160
190
  end
161
191
  end
162
192
 
193
+ # @private
194
+ def self.find_gemfile(search_dir, gemfile_names: nil)
195
+ gemfile_names ||= DEFAULT_GEMFILE_NAMES
196
+ Array(gemfile_names).each do |file|
197
+ gemfile_path = ::File.join(search_dir, file)
198
+ return gemfile_path if ::File.readable?(gemfile_path)
199
+ end
200
+ nil
201
+ end
202
+
163
203
  @global_mutex = ::Monitor.new
164
204
 
165
- ## @private
205
+ # @private
166
206
  def self.synchronize(&block)
167
207
  @global_mutex.synchronize(&block)
168
208
  end
@@ -230,14 +270,6 @@ module Toys
230
270
  raise ActivationFailedError, err.message
231
271
  end
232
272
 
233
- def find_gemfile(search_dirs)
234
- search_dirs.each do |dir|
235
- gemfile_path = ::File.join(dir, "Gemfile")
236
- return gemfile_path if ::File.readable?(gemfile_path)
237
- end
238
- raise GemfileNotFoundError, "Gemfile not found"
239
- end
240
-
241
273
  def configure_gemfile(gemfile_path)
242
274
  old_path = ::ENV["BUNDLE_GEMFILE"]
243
275
  if old_path
@@ -258,13 +290,13 @@ module Toys
258
290
  def setup_bundle(gemfile_path, groups)
259
291
  begin
260
292
  modify_bundle_definition(gemfile_path)
261
- ::Bundler.setup(*groups)
262
- rescue ::Bundler::GemNotFound
293
+ ::Bundler.ui.silence { ::Bundler.setup(*groups) }
294
+ rescue ::Bundler::GemNotFound, ::Bundler::VersionConflict
263
295
  restore_toys_libs
264
296
  install_bundle(gemfile_path)
265
297
  ::Bundler.reset!
266
298
  modify_bundle_definition(gemfile_path)
267
- ::Bundler.setup(*groups)
299
+ ::Bundler.ui.silence { ::Bundler.setup(*groups) }
268
300
  end
269
301
  restore_toys_libs
270
302
  end
@@ -272,24 +304,38 @@ module Toys
272
304
  def modify_bundle_definition(gemfile_path)
273
305
  builder = ::Bundler::Dsl.new
274
306
  builder.eval_gemfile(gemfile_path)
275
- begin
276
- builder.eval_gemfile(::File.join(__dir__, "gems", "gemfile.rb"))
277
- rescue ::Bundler::Dsl::DSLError
278
- terminal.puts(
279
- "WARNING: Unable to integrate your Gemfile into the Toys runtime.\n" \
280
- "When using the Toys Bundler integration features, do NOT list\n" \
281
- "the toys or toys-core gems directly in your Gemfile. They can be\n" \
282
- "dependencies of another gem, but cannot be listed directly.",
283
- :red
284
- )
285
- return
286
- end
287
307
  toys_gems = ["toys-core"]
288
- toys_gems << "toys" if ::Toys.const_defined?(:VERSION)
308
+ remove_gem_from_definition(builder, "toys-core")
309
+ removed_toys = remove_gem_from_definition(builder, "toys")
310
+ add_gem_to_definition(builder, "toys-core")
311
+ if removed_toys || ::Toys.const_defined?(:VERSION)
312
+ add_gem_to_definition(builder, "toys")
313
+ toys_gems << "toys"
314
+ end
289
315
  definition = builder.to_definition(gemfile_path + ".lock", { gems: toys_gems })
290
316
  ::Bundler.instance_variable_set(:@definition, definition)
291
317
  end
292
318
 
319
+ def remove_gem_from_definition(builder, name)
320
+ existing_dep = builder.dependencies.find { |dep| dep.name == name }
321
+ return false unless existing_dep
322
+ unless existing_dep.requirement.satisfied_by?(::Gem::Version.new(::Toys::Core::VERSION))
323
+ raise IncompatibleToysError,
324
+ "The bundle lists #{name} #{existing_dep.requirement} as a dependency, which is" \
325
+ " incompatible with the current version #{::Toys::Core::VERSION}."
326
+ end
327
+ builder.dependencies.delete(existing_dep)
328
+ true
329
+ end
330
+
331
+ def add_gem_to_definition(builder, name)
332
+ if ::ENV["TOYS_DEV"] == "true"
333
+ path = ::File.join(::File.dirname(::File.dirname(::Toys::CORE_LIB_PATH)), name)
334
+ end
335
+ command = "gem #{name.inspect}, #{::Toys::Core::VERSION.inspect}, path: #{path.inspect}\n"
336
+ builder.eval_gemfile("current #{name}", command)
337
+ end
338
+
293
339
  def restore_toys_libs
294
340
  $LOAD_PATH.delete(::Toys::CORE_LIB_PATH)
295
341
  $LOAD_PATH.unshift(::Toys::CORE_LIB_PATH)
@@ -319,7 +365,12 @@ module Toys
319
365
  " `cd #{gemfile_dir} && bundle install`"
320
366
  end
321
367
  require "bundler/cli"
322
- ::Bundler::CLI.start(["install"])
368
+ begin
369
+ ::Bundler::CLI.start(["install"])
370
+ rescue ::Bundler::GemNotFound, ::Bundler::InstallError
371
+ terminal.puts("Failed to install. Trying update...")
372
+ ::Bundler::CLI.start(["update"])
373
+ end
323
374
  end
324
375
  end
325
376
  end