Class: RepoTender::Shell

Inherits:
Object
  • Object
show all
Defined in:
lib/repo_tender/shell.rb

Overview

Thin Open3.capture3 wrapper that:

* requires an ambient Async::Task (so subprocess I/O flows through
  Ruby's Fiber scheduler → kqueue on macOS and is non-blocking);
* returns a Dry::Monads::Result — Success(stdout) on zero exit,
  Failure({argv:, stderr:, status:}) otherwise.

Per AGENTS.md: no ‘async-process`. Per PRD §2: boundaries return Result, exceptions are for programmer error only.

Class Method Summary collapse

Class Method Details

.run(*argv, chdir: nil, env: nil) ⇒ Object

Raises:

  • (ArgumentError)


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
# File 'lib/repo_tender/shell.rb', line 22

def self.run(*argv, chdir: nil, env: nil)
  raise ArgumentError, "Shell.run requires at least argv" if argv.empty?
  raise "Shell.run must be called inside an ambient Async::Task" unless Async::Task.current?

  full_env = env ? ENV.to_h.merge(env.transform_keys(&:to_s)) : nil
  opts = {}
  opts[:chdir] = chdir if chdir
  # Open3.capture3: env is a leading hash positional arg, not a kwarg.
  #
  # Open3.capture3 spawns the child with two internal reader
  # threads (one for stdout, one for stderr; see
  # `rubylibdir/open3.rb` ~L644: `out_reader = Thread.new { o.read }`
  # / `err_reader = Thread.new { e.read }`). When the `popen3`
  # block exits via exception (e.g. the user ^C'd mid-Shell.run
  # via SIGINT), `popen_run`'s ensure closes the read pipes from
  # the main thread while those reader threads are still inside
  # `o.read` / `e.read`. The mid-read close races with the reader
  # and raises `IOError: stream closed in another thread` in the
  # reader thread. With the default `Thread.report_on_exception
  # = true` (since Ruby 2.5), Ruby prints a multi-line backtrace
  # to stderr for that orphaned thread — exactly the noise
  # Slice 6 G3 silences.
  #
  # We bracket the `Open3.capture3` call with a save/restore of
  # `Thread.report_on_exception = false`. This is targeted
  # because, at this code site, the ONLY threads in flight are:
  #   * the main thread (this method's caller);
  #   * Async's internal `io_select` thread
  #     (`async/lib/async/scheduler.rb` L425) — which silences
  #     its own report (`Thread.current.report_on_exception =
  #     false` on that thread, not globally);
  #   * the Open3 reader threads (the source of the noise).
  # `lib/` has zero `Thread.new` calls; `dry-cli`, `dry-monads`,
  # `dry-validation`, `dry-struct`, `dry-types`, `dry-schema`,
  # `xdg` have none either (verified Slice 6 PHASE 0). So we are
  # NOT hiding any app-owned worker-thread crashes — the only
  # thread that can raise here is the Open3 reader thread, and
  # the only thing it can raise is the IOError we explicitly
  # want to silence. The original value is restored in `ensure`
  # so we never leak the suppression past this call.
  # Refcount the active Shell.run calls so the global flag is suppressed
  # for the entire overlapping window, not just per-fiber. On 0→1: capture
  # original and set false. On 1→0 (in ensure): restore the original.
  # Safe without a Mutex: the reactor is single-threaded; fibers only yield
  # at Open3.capture3's thread-join, never between these plain assignments.
  if @run_count == 0
    @saved_roe = Thread.report_on_exception
    Thread.report_on_exception = false
  end
  @run_count += 1
  begin
    stdout, stderr, status = if full_env
      Open3.capture3(full_env, *argv, **opts)
    else
      Open3.capture3(*argv, **opts)
    end
  ensure
    @run_count -= 1
    Thread.report_on_exception = @saved_roe if @run_count == 0
  end

  if status.success?
    Success(stdout)
  else
    Failure({argv: argv, stderr: stderr, status: status.exitstatus})
  end
end