Skip to content
Kward Search API index

Class: Kward::LocalPtyCommandRunner

Inherits:
Object
  • Object
show all
Defined in:
lib/kward/local_pty_command_runner.rb

Overview

Low-level pseudo-terminal command runner with bounded capture, timeout, cancellation, and optional output streaming. This gives child processes a TTY without trying to emulate an interactive terminal.

Constant Summary collapse

Result =
LocalCommandRunner::Result
READ_SIZE =
4096
DEFAULT_ROWS =
24
DEFAULT_COLUMNS =
80

Instance Method Summary collapse

Constructor Details

#initialize(timeout_seconds:, max_output_bytes:, terminate_on_output_limit: false, window_size_provider: nil) ⇒ LocalPtyCommandRunner

Returns a new instance of LocalPtyCommandRunner.



19
20
21
22
23
24
25
# File 'lib/kward/local_pty_command_runner.rb', line 19

def initialize(timeout_seconds:, max_output_bytes:, terminate_on_output_limit: false, window_size_provider: nil)
  @timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : 30
  @max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : 128 * 1024
  @terminate_on_output_limit = terminate_on_output_limit
  @window_size_provider = window_size_provider
  @window_size = nil
end

Instance Method Details

#run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block) ⇒ Object



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
# File 'lib/kward/local_pty_command_runner.rb', line 27

def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block)
  cancellation&.raise_if_cancelled!
  output = +""
  captured_bytes = 0
  truncated = false
  timed_out = false
  cancelled = false
  pid = nil
  status = nil

  PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, _writer, child_pid|
    pid = child_pid
    update_window_size(reader, pid)
    cancellation&.on_cancel do
      cancelled = true
      terminate_process_group(pid)
    end

    begin
      deadline = Time.now + @timeout_seconds
      loop do
        cancellation&.raise_if_cancelled!
        raise Timeout::Error if Time.now >= deadline

        update_window_size(reader, pid)
        readable, = IO.select([reader], nil, nil, 0.02)
        next unless readable

        chunk = read_chunk(reader)
        break if chunk.nil?

        chunk = normalize_line_endings(chunk)
        captured_bytes, truncated, captured_chunk = capture_chunk(chunk, output, captured_bytes, truncated)
        block&.call(:stdout, captured_chunk) unless captured_chunk.empty?
        terminate_process_group(pid) if truncated && @terminate_on_output_limit
      end
    rescue Errno::EIO
      nil
    end

    status = wait_for_status(pid)
  end

  cancellation&.raise_if_cancelled! if cancelled
  Result.new(stdout: output, stderr: "", exit_status: exit_status(status), timed_out: false, truncated: truncated)
rescue Timeout::Error
  timed_out = true
  terminate_process_group(pid) if pid
  wait_for_status(pid) if pid
  Result.new(stdout: output, stderr: "", exit_status: nil, timed_out: timed_out, truncated: truncated)
rescue Cancellation::CancelledError
  terminate_process_group(pid) if pid
  wait_for_status(pid) if pid
  raise
end