Class: ShellSession

Inherits:
Object show all
Defined in:
lib/shell_session.rb

Overview

ShellSession provides an interactive bash session to execute commands, capturing both standard output and standard error separately with timestamps. Each output line is stored as a hash containing its text and the time it was received.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(setup_command = nil) ⇒ ShellSession

Returns a new instance of ShellSession.



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
# File 'lib/shell_session.rb', line 17

def initialize(setup_command = nil)
  # Open a persistent bash session with separate stdout and stderr streams.
  @stdin, @stdout_stream, @stderr_stream, @wait_thr = Open3.popen3('bash')
  @stdin.flush

  # Random boundary marker between commands.
  @boundary = Random.new.rand.to_s

  # Line sequence counter
  @line_counter = 0

  # Most recent command.
  @command = ''

  # Buffers for captured output.
  @stdout_lines = []
  @stderr_lines = []
  @output = ''
  @lines = []
  @stdout = ''
  @stderr = ''
  @waiting_for_input = false

  return @exitstatus = STATUS_SUCCESS if setup_command.nil? || setup_command.strip.empty?

  run_command(setup_command)
end

Instance Attribute Details

#exitstatusObject (readonly)

Returns the value of attribute exitstatus.



14
15
16
# File 'lib/shell_session.rb', line 14

def exitstatus
  @exitstatus
end

#linesObject (readonly)

Returns the value of attribute lines.



14
15
16
# File 'lib/shell_session.rb', line 14

def lines
  @lines
end

#outputObject (readonly)

Returns the value of attribute output.



14
15
16
# File 'lib/shell_session.rb', line 14

def output
  @output
end

#stderrObject (readonly)

Returns the value of attribute stderr.



14
15
16
# File 'lib/shell_session.rb', line 14

def stderr
  @stderr
end

#stderr_linesObject (readonly)

Returns the value of attribute stderr_lines.



14
15
16
# File 'lib/shell_session.rb', line 14

def stderr_lines
  @stderr_lines
end

#stdoutObject (readonly)

Returns the value of attribute stdout.



14
15
16
# File 'lib/shell_session.rb', line 14

def stdout
  @stdout
end

#stdout_linesObject (readonly)

Returns the value of attribute stdout_lines.



14
15
16
# File 'lib/shell_session.rb', line 14

def stdout_lines
  @stdout_lines
end

#waiting_for_inputObject (readonly)

Returns the value of attribute waiting_for_input.



14
15
16
# File 'lib/shell_session.rb', line 14

def waiting_for_input
  @waiting_for_input
end

Instance Method Details

#closeObject

Closes the bash session.



179
180
181
182
183
184
185
186
# File 'lib/shell_session.rb', line 179

def close
  @stdin.puts 'exit'
  @stdin.flush
  @stdin.close
  @stdout_stream.close
  @stderr_stream.close
  @wait_thr.join
end

#finalizeObject

Ensures the shell session is closed when the object is garbage collected.



189
190
191
# File 'lib/shell_session.rb', line 189

def finalize
  close unless @stdin.closed?
end

#run_command(command, interactive = false) ⇒ Hash

Executes a command in the bash session and returns its output. Stdout and stderr are captured separately with a timestamp for each line.

Parameters:

  • command (String)

    The command to execute.

  • interactive (Boolean) (defaults to: false)

    Whether this command expects user input

Returns:

  • (Hash)

    A hash containing :status, :output (stdout), :stdout, and :stderr.



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
# File 'lib/shell_session.rb', line 51

def run_command(command, interactive = false)
  @exitstatus = STATUS_SUCCESS
  @command = command
  @waiting_for_input = false
  return { output: '', status: STATUS_SUCCESS,
           stderr: '', stdout: '' } if command.nil? || command.strip.empty?

  # Clear previous command output.
  @stdout_lines = []
  @stderr_lines = []

  # Send the command to the shell.
  @stdin.puts command
  @stdin.flush
  
  # For non-interactive commands, immediately send the boundary marker
  unless interactive
    @stdin.puts "\n_exitstatus=\"$?\""
    @stdin.puts "echo '#{@boundary}'"
    @stdin.puts 'echo $_exitstatus'
    @stdin.flush
  end

  done = false
  # Add timeout to prevent infinite loops
  timeout = Time.now + 10 # 10 second timeout

  # Loop to concurrently read stdout and stderr.
  until done
    # Check if stdin is ready for writing (process is waiting for input)
    begin
      stdin_ready = IO.select(nil, [@stdin], nil, 0)
      @waiting_for_input = interactive && !stdin_ready.nil? && stdin_ready[1].include?(@stdin)
    rescue IOError, Errno::EBADF
      # Handle closed file descriptors
      @waiting_for_input = false
    end
    
    begin
      ready = IO.select([@stdout_stream, @stderr_stream], nil, nil, 0.1)
    rescue IOError, Errno::EBADF
      # Handle closed file descriptors
      break
    end
    
    if Time.now > timeout
      @waiting_for_input = false
      # Send boundary marker to finish the command
      @stdin.puts "\n_exitstatus=\"$?\""
      @stdin.puts "echo '#{@boundary}'"
      @stdin.puts 'echo $_exitstatus'
      @stdin.flush
      break
    end

    next unless ready # Skip if no IO is ready

    ready[0].each do |io|
      if io == @stdout_stream
        begin
          line = @stdout_stream.gets
        rescue IOError
          next
        end
        next if line.nil?

        ts = Time.now
        if line.include?(@boundary)
          prefix = line[0...line.index(@boundary)]
          add_stdout_line(timestamp: ts, line: prefix) unless prefix.empty?
          # Read exit status from the next stdout line.
          begin
            status_line = @stdout_stream.gets
            @exitstatus = status_line.strip.to_i if status_line
          rescue IOError
            # Handle closed file descriptors
          end
          done = true
          break
        else
          add_stdout_line(timestamp: ts, line: line)
        end
      elsif io == @stderr_stream
        begin
          line = @stderr_stream.gets
        rescue IOError
          next
        end
        next if line.nil?

        ts = Time.now
        add_stderr_line(timestamp: ts, line: line)
      end
    end
  end

  # Replace the potentially hanging stderr capture with a non-blocking check
  begin
    if (ready = IO.select([@stderr_stream], nil, nil, 0.1))
      ready[0].each do |io|
        begin
          while io.wait_readable(0.1) && (line = io.read_nonblock(4096))
            ts = Time.now
            line.each_line do |l|
              add_stderr_line(timestamp: ts, line: l)
            end
          end
        rescue IO::WaitReadable, IOError, Errno::EBADF
          # No more data available right now or closed file descriptor
        rescue EOFError
          # End of stream reached
        end
      end
    end
  rescue IOError, Errno::EBADF
    # Handle closed file descriptors
  end

  # Prepare outputs.
  @output = @stdout_lines.map { |entry| entry[:line] }.join
  @stdout = @output
  @stderr = @stderr_lines.map { |entry| entry[:line] }.join
  @lines = @stdout_lines.map { |entry| entry[:line] }

  { status: @exitstatus, output: @output, stdout: @stdout, stderr: @stderr }
end

#send_input(input) ⇒ Object

Sends input to the running process

Parameters:

  • input (String)

    The input to send to the process



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
# File 'lib/shell_session.rb', line 195

def send_input(input)
  return unless @waiting_for_input
  
  begin
    @stdin.puts(input)
    @stdin.flush
  rescue IOError, Errno::EBADF
    @waiting_for_input = false
    return
  end
  
  # After sending input, we need to check if we're still waiting
  # Give a small delay for the process to consume the input
  sleep 0.2
  
  # Check if stdin is still ready for writing
  begin
    stdin_ready = IO.select(nil, [@stdin], nil, 0)
    @waiting_for_input = !stdin_ready.nil? && stdin_ready[1].include?(@stdin)
  rescue IOError, Errno::EBADF
    @waiting_for_input = false
  end
  
  # If we're no longer waiting for input, send the boundary marker
  unless @waiting_for_input
    begin
      @stdin.puts "\n_exitstatus=\"$?\""
      @stdin.puts "echo '#{@boundary}'"
      @stdin.puts 'echo $_exitstatus'
      @stdin.flush
    rescue IOError, Errno::EBADF
      # Handle closed file descriptors
    end
  end
end

#waiting_for_input?Boolean

Checks if the process is waiting for input

Returns:

  • (Boolean)

    True if the process is waiting for input



233
234
235
# File 'lib/shell_session.rb', line 233

def waiting_for_input?
  @waiting_for_input
end