1require 'open3'
2
3module Gitlab
4  module Git
5    module Popen
6      FAST_GIT_PROCESS_TIMEOUT = 15.seconds
7
8      def popen(cmd, path, vars = {}, include_stderr: true, lazy_block: nil)
9        raise "System commands must be given as an array of strings" unless cmd.is_a?(Array)
10
11        path ||= Dir.pwd
12        vars['PWD'] = path
13        options = { chdir: path }
14
15        cmd_output = ""
16        cmd_status = 0
17        Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
18          stdout.set_encoding(Encoding::ASCII_8BIT)
19          stderr.set_encoding(Encoding::ASCII_8BIT)
20
21          # stderr and stdout pipes can block if stderr/stdout aren't drained: https://bugs.ruby-lang.org/issues/9082
22          # Mimic what Ruby does with capture3: https://github.com/ruby/ruby/blob/1ec544695fa02d714180ef9c34e755027b6a2103/lib/open3.rb#L257-L273
23          err_reader = Thread.new { stderr.read }
24
25          begin
26            yield(stdin) if block_given?
27            stdin.close
28
29            if lazy_block
30              cmd_output = lazy_block.call(stdout.lazy)
31              cmd_status = 0
32              break
33            else
34              cmd_output << stdout.read
35            end
36
37            cmd_output << err_reader.value if include_stderr
38            cmd_status = wait_thr.value.exitstatus
39          ensure
40            # When Popen3.open3 returns, the stderr reader gets closed, which causes
41            # an exception in the err_reader thread. Kill the thread before
42            # returning from Popen3.open3.
43            err_reader.kill
44          end
45        end
46
47        [cmd_output, cmd_status]
48      end
49
50      def popen_with_timeout(cmd, timeout, path, vars = {})
51        raise "System commands must be given as an array of strings" unless cmd.is_a?(Array)
52
53        path ||= Dir.pwd
54        vars['PWD'] = path
55
56        FileUtils.mkdir_p(path) unless File.directory?(path)
57
58        rout, wout = IO.pipe
59        rerr, werr = IO.pipe
60
61        pid = Process.spawn(vars, *cmd, out: wout, err: werr, chdir: path, pgroup: true)
62        # stderr and stdout pipes can block if stderr/stdout aren't drained: https://bugs.ruby-lang.org/issues/9082
63        # Mimic what Ruby does with capture3: https://github.com/ruby/ruby/blob/1ec544695fa02d714180ef9c34e755027b6a2103/lib/open3.rb#L257-L273
64        out_reader = Thread.new { rout.read }
65        err_reader = Thread.new { rerr.read }
66
67        begin
68          # close write ends so we could read them
69          wout.close
70          werr.close
71
72          status = process_wait_with_timeout(pid, timeout)
73
74          cmd_output = out_reader.value
75          cmd_output << err_reader.value # Copying the behaviour of `popen` which merges stderr into output
76
77          [cmd_output, status.exitstatus]
78        rescue Timeout::Error => e
79          kill_process_group_for_pid(pid)
80
81          raise e
82        ensure
83          wout.close unless wout.closed?
84          werr.close unless werr.closed?
85
86          # rout is shared with out_reader. To prevent an exception in that
87          # thread, kill the thread before closing rout. The same goes for rerr
88          # below.
89          out_reader.kill
90          rout.close
91
92          err_reader.kill
93          rerr.close
94        end
95      end
96
97      def process_wait_with_timeout(pid, timeout)
98        deadline = timeout.seconds.from_now
99        wait_time = 0.01
100
101        while deadline > Time.now
102          sleep(wait_time)
103          _, status = Process.wait2(pid, Process::WNOHANG)
104
105          return status unless status.nil?
106        end
107
108        raise Timeout::Error, "Timeout waiting for process ##{pid}"
109      end
110
111      def kill_process_group_for_pid(pid)
112        Process.kill("KILL", -pid)
113        Process.wait(pid)
114      rescue Errno::ESRCH
115      end
116    end
117  end
118end
119