File: spawn_process.rb

package info (click to toggle)
ruby-aruba 2.3.3-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 968 kB
  • sloc: javascript: 6,850; ruby: 4,010; makefile: 5
file content (370 lines) | stat: -rw-r--r-- 9,003 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
# frozen_string_literal: true

require 'tempfile'
require 'shellwords'

require 'aruba/errors'
require 'aruba/processes/basic_process'
require 'aruba/platform'

# Aruba
module Aruba
  # Platforms
  module Processes
    # Wrapper around Process.spawn that broadly follows the ChildProcess interface
    # @private
    class ProcessRunner
      def initialize(command_array)
        @command_array = command_array
        @exit_status = nil
      end

      attr_accessor :stdout, :stderr, :cwd, :environment
      attr_reader :command_array, :pid

      def start
        @stdin_r, @stdin_w = IO.pipe
        @pid = Process.spawn(environment, *command_array,
                             unsetenv_others: true,
                             in: @stdin_r,
                             out: stdout.fileno,
                             err: stderr.fileno,
                             close_others: true,
                             chdir: cwd)
      end

      def stdin
        @stdin_w
      end

      def stop
        return if @exit_status

        if Aruba.platform.term_signal_supported?
          send_signal 'TERM'
          return if poll_for_exit(3)
        end

        send_signal 'KILL'
        wait
      end

      def wait
        _, status = Process.waitpid2 @pid
        @exit_status = status
      end

      def exited?
        return true if @exit_status

        pid, status = Process.waitpid2 @pid, Process::WNOHANG | Process::WUNTRACED

        if pid
          @exit_status = status
          return true
        end

        false
      end

      def poll_for_exit(exit_timeout)
        start = Time.now
        wait_until = start + exit_timeout
        loop do
          return true if exited?
          break if Time.now >= wait_until

          sleep 0.1
        end
        false
      end

      def exit_code
        @exit_status&.exitstatus
      end

      private

      def send_signal(signal)
        Process.kill signal, @pid
      end
    end

    # Spawn a process for command
    #
    # `SpawnProcess` is not meant for direct use - `SpawnProcess.new` - by
    # users. Only it's public methods are part of the public API of aruba, e.g.
    # `#stdin`, `#stdout`.
    #
    # @private
    class SpawnProcess < BasicProcess
      # Use as default launcher
      def self.match?(_mode)
        true
      end

      # Create process
      #
      # @param [String] cmd
      #   Command string
      #
      # @param [Numeric] exit_timeout
      #   The timeout until we expect the command to be finished
      #
      # @param [Numeric] io_wait_timeout
      #   The timeout until we expect the io to be finished
      #
      # @param [String] working_directory
      #   The directory where the command will be executed
      #
      # @param [Hash] environment
      #   Environment variables
      #
      # @param [Class] main_class
      #   E.g. Cli::App::Runner
      #
      # @param [String] stop_signal
      #   Name of signal to send to stop process. E.g. 'HUP'.
      #
      # @param [Numeric] startup_wait_time
      #   The amount of seconds to wait after Aruba has started a command.
      def initialize(cmd, exit_timeout, io_wait_timeout, working_directory, # rubocop:disable Metrics/ParameterLists
                     environment = Aruba.platform.environment_variables.hash_from_env,
                     main_class = nil, stop_signal = nil, startup_wait_time = 0)
        super

        @process      = nil
        @stdout_cache = nil
        @stderr_cache = nil
      end

      # Run the command
      #
      # @yield [SpawnProcess]
      #   Run code for process which was started
      #
      def start
        if started?
          error_message =
            "Command \"#{commandline}\" has already been started. " \
            'Please `#stop` the command first and `#start` it again. ' \
            'Alternatively use `#restart`.'
          raise CommandAlreadyStartedError, error_message
        end

        @started = true

        @process = ProcessRunner.new(command_string.to_a)

        @stdout_file = Tempfile.new('aruba-stdout-')
        @stderr_file = Tempfile.new('aruba-stderr-')

        @stdout_file.sync = true
        @stderr_file.sync = true

        @stdout_file.set_encoding('ASCII-8BIT')
        @stderr_file.set_encoding('ASCII-8BIT')

        @exit_status = nil

        before_run

        @process.stdout = @stdout_file
        @process.stderr = @stderr_file
        @process.cwd    = @working_directory

        @process.environment = environment

        begin
          @process.start
          sleep startup_wait_time
        rescue SystemCallError => e
          raise LaunchError, "It tried to start #{commandline}. " + e.message
        end

        after_run

        yield self if block_given?
      end

      # Access to stdin of process
      def stdin
        return if @process.exited?

        @process.io.stdin
      end

      # Access to stdout of process
      #
      # @param [Hash] opts
      #   Options
      #
      # @option [Integer] wait_for_io
      #   Wait for IO to be finished
      #
      # @return [String]
      #   The content of stdout
      def stdout(opts = {})
        return @stdout_cache if stopped?

        wait_for_io opts.fetch(:wait_for_io, io_wait_timeout) do
          @process.stdout.flush
          open(@stdout_file.path).read
        end
      end

      # Access to stderr of process
      #
      # @param [Hash] opts
      #   Options
      #
      # @option [Integer] wait_for_io
      #   Wait for IO to be finished
      #
      # @return [String]
      #   The content of stderr
      def stderr(opts = {})
        return @stderr_cache if stopped?

        wait_for_io opts.fetch(:wait_for_io, io_wait_timeout) do
          @process.stderr.flush
          open(@stderr_file.path).read
        end
      end

      def write(input)
        return if stopped?

        @process.stdin.write(input)
        @process.stdin.flush

        self
      end

      # Close io
      def close_io(name)
        return if stopped?

        @process.public_send(name.to_sym).close
      end

      # Stop command
      def stop(*)
        return @exit_status if stopped?

        @process.poll_for_exit(@exit_timeout) or @timed_out = true

        terminate
      end

      # Wait for command to finish
      def wait
        @process.wait
      end

      # Terminate command
      def terminate
        return @exit_status if stopped?

        unless @process.exited?
          if @stop_signal
            # send stop signal ...
            send_signal @stop_signal
            # ... and set the exit status
            wait
          else
            begin
              @process.stop
            rescue Errno::EPERM # This can occur on MacOS
              nil
            end
          end
        end

        @exit_status = @process.exit_code

        @stdout_cache = read_temporary_output_file @stdout_file
        @stderr_cache = read_temporary_output_file @stderr_file

        @started = false

        @exit_status
      end

      # Output pid of process
      #
      # This is the PID of the spawned process.
      def pid
        @process.pid
      end

      # Send command a signal
      #
      # @param [String] signal
      #   The signal, i.e. 'TERM'
      def send_signal(signal)
        error_message = %(Command "#{commandline}" with PID "#{pid}" has already stopped.)
        raise CommandAlreadyStoppedError, error_message if @process.exited?

        Process.kill signal, pid
      rescue Errno::ESRCH
        raise CommandAlreadyStoppedError, error_message
      end

      # Return file system stats for the given command
      #
      # @return [Aruba::Platforms::FilesystemStatus]
      #   This returns a File::Stat-object
      def filesystem_status
        Aruba.platform.filesystem_status.new(command_path)
      end

      # Content of command
      #
      # @return [String]
      #   The content of the script/command. This might be binary output as
      #   string if your command is a binary executable.
      def content
        File.read command_path
      end

      def interactive?
        true
      end

      private

      def command_string
        if command_path.nil?
          raise LaunchError,
                %(Command "#{command}" not found in PATH-variable "#{environment['PATH']}".)
        end

        Aruba.platform.command_string.new(command_path, *arguments)
      end

      def command_path
        @command_path ||=
          if Aruba.platform.builtin_shell_commands.include?(command)
            command
          else
            Aruba.platform.which(command, environment['PATH'])
          end
      end

      def wait_for_io(time_to_wait)
        sleep time_to_wait
        yield
      end

      def read_temporary_output_file(file)
        file.flush
        file.rewind
        data = file.read
        file.close

        data.force_encoding('UTF-8')
      end
    end
  end
end