Module: Mbeditor::ProcessRunner

Defined in:
app/services/mbeditor/process_runner.rb

Defined Under Namespace

Classes: TimeoutError

Class Method Summary collapse

Class Method Details

.call(cmd, timeout: nil, env: {}, stdin_data: nil, chdir: nil, max_bytes: nil) ⇒ Object

max_bytes bounds how much of each stream is kept in memory (nil = unbounded). Anything past the cap is still read and discarded — stopping would block the child on a full pipe and hang the wait below.

Raises:



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
# File 'app/services/mbeditor/process_runner.rb', line 17

def call(cmd, timeout: nil, env: {}, stdin_data: nil, chdir: nil, max_bytes: nil)
  out = +""
  err = +""
  exit_status = nil
  timed_out = false

  opts = { pgroup: true }
  opts[:chdir] = chdir if chdir

  Open3.popen3(env, *cmd, **opts) do |stdin, stdout, stderr, wait_thr|
    stdin.write(stdin_data) if stdin_data
    stdin.close

    out_thread = Thread.new { out = read_capped(stdout, max_bytes) }
    err_thread = Thread.new { err = read_capped(stderr, max_bytes) }

    # A deadline join rather than a timer thread: a timer racing normal
    # exit could flag a timeout (and SIGKILL a recycled pid) after the
    # process had already succeeded. The reader threads above keep the
    # pipes drained meanwhile, so this cannot deadlock.
    if timeout
      deadline  = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
      timed_out = !wait_thr.join(timeout)

      # A grandchild that inherited the pipe holds it open after the child
      # exits, so the unbounded joins below could outlive the process
      # itself. Bound them by the same deadline the timer thread enforced.
      remaining = [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
      overrun   = !out_thread.join(remaining) || !err_thread.join(remaining)

      if timed_out || overrun
        begin
          Process.kill("-KILL", wait_thr.pid)
        rescue Errno::ESRCH
          nil
        end
      end
    end

    out_thread.join
    err_thread.join
    exit_status = wait_thr.value
  end

  raise TimeoutError, "process timed out after #{timeout}s" if timed_out

  { stdout: out, stderr: err, exit_status: exit_status }
end

.read_capped(io, max_bytes) ⇒ Object



66
67
68
69
70
71
72
73
74
75
76
# File 'app/services/mbeditor/process_runner.rb', line 66

def read_capped(io, max_bytes)
  return io.read.to_s unless max_bytes

  buf = +""
  while (chunk = io.read(CHUNK_BYTES))
    buf << chunk if buf.bytesize < max_bytes
  end
  # IO#read with a length returns binary; IO#read without one applies the
  # default external encoding, and callers expect the latter.
  buf.force_encoding(Encoding.default_external)
end