Class: ShellSession
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
-
#exitstatus ⇒ Object
readonly
Returns the value of attribute exitstatus.
-
#lines ⇒ Object
readonly
Returns the value of attribute lines.
-
#output ⇒ Object
readonly
Returns the value of attribute output.
-
#stderr ⇒ Object
readonly
Returns the value of attribute stderr.
-
#stderr_lines ⇒ Object
readonly
Returns the value of attribute stderr_lines.
-
#stdout ⇒ Object
readonly
Returns the value of attribute stdout.
-
#stdout_lines ⇒ Object
readonly
Returns the value of attribute stdout_lines.
-
#waiting_for_input ⇒ Object
readonly
Returns the value of attribute waiting_for_input.
Instance Method Summary collapse
-
#close ⇒ Object
Closes the bash session.
-
#finalize ⇒ Object
Ensures the shell session is closed when the object is garbage collected.
-
#initialize(setup_command = nil) ⇒ ShellSession
constructor
A new instance of ShellSession.
-
#run_command(command, interactive = false) ⇒ Hash
Executes a command in the bash session and returns its output.
-
#send_input(input) ⇒ Object
Sends input to the running process.
-
#waiting_for_input? ⇒ Boolean
Checks if the process is waiting for input.
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
#exitstatus ⇒ Object (readonly)
Returns the value of attribute exitstatus.
14 15 16 |
# File 'lib/shell_session.rb', line 14 def exitstatus @exitstatus end |
#lines ⇒ Object (readonly)
Returns the value of attribute lines.
14 15 16 |
# File 'lib/shell_session.rb', line 14 def lines @lines end |
#output ⇒ Object (readonly)
Returns the value of attribute output.
14 15 16 |
# File 'lib/shell_session.rb', line 14 def output @output end |
#stderr ⇒ Object (readonly)
Returns the value of attribute stderr.
14 15 16 |
# File 'lib/shell_session.rb', line 14 def stderr @stderr end |
#stderr_lines ⇒ Object (readonly)
Returns the value of attribute stderr_lines.
14 15 16 |
# File 'lib/shell_session.rb', line 14 def stderr_lines @stderr_lines end |
#stdout ⇒ Object (readonly)
Returns the value of attribute stdout.
14 15 16 |
# File 'lib/shell_session.rb', line 14 def stdout @stdout end |
#stdout_lines ⇒ Object (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_input ⇒ Object (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
#close ⇒ Object
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 |
#finalize ⇒ Object
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.
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
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
233 234 235 |
# File 'lib/shell_session.rb', line 233 def waiting_for_input? @waiting_for_input end |