Class: Consolle::Server::ConsoleSupervisor
- Inherits:
-
Object
- Object
- Consolle::Server::ConsoleSupervisor
- Defined in:
- lib/consolle/server/console_supervisor.rb
Constant Summary collapse
- RESTART_DELAY =
seconds
1
- MAX_RESTARTS =
within 5 minutes
5
- RESTART_WINDOW =
5 minutes
300
- PROMPT_PATTERN =
Match various Rails console prompts Match various console prompts: custom sentinel, Rails app prompts, IRB prompts, and generic prompts Allow optional non-word characters before the prompt (e.g., Unicode symbols like ▽)
/^[^\w]*(\u001E\u001F<CONSOLLE>\u001F\u001E|\w+[-_]?\w*\([^)]*\)>|irb\([^)]+\):\d+:?\d*[>*]|>>|>)\s*$/
- CTRL_C =
"\x03"
Instance Attribute Summary collapse
-
#logger ⇒ Object
readonly
Returns the value of attribute logger.
-
#pid ⇒ Object
readonly
Returns the value of attribute pid.
-
#rails_env ⇒ Object
readonly
Returns the value of attribute rails_env.
-
#rails_root ⇒ Object
readonly
Returns the value of attribute rails_root.
-
#reader ⇒ Object
readonly
Returns the value of attribute reader.
-
#writer ⇒ Object
readonly
Returns the value of attribute writer.
Instance Method Summary collapse
- #eval(code, timeout: nil, pre_sigint: nil) ⇒ Object
-
#initialize(rails_root:, rails_env: 'development', logger: nil, command: nil, wait_timeout: nil) ⇒ ConsoleSupervisor
constructor
A new instance of ConsoleSupervisor.
- #restart ⇒ Object
- #running? ⇒ Boolean
- #stop ⇒ Object
Constructor Details
#initialize(rails_root:, rails_env: 'development', logger: nil, command: nil, wait_timeout: nil) ⇒ ConsoleSupervisor
Returns a new instance of ConsoleSupervisor.
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/consolle/server/console_supervisor.rb', line 31 def initialize(rails_root:, rails_env: 'development', logger: nil, command: nil, wait_timeout: nil) @rails_root = rails_root @rails_env = rails_env @command = command || 'bin/rails console' @logger = logger || Logger.new(STDOUT) @wait_timeout = wait_timeout || Consolle::DEFAULT_WAIT_TIMEOUT @pid = nil @reader = nil @writer = nil @running = false @restart_timestamps = [] @watchdog_thread = nil @mutex = Mutex.new @process_mutex = Mutex.new # Separate mutex for process lifecycle management spawn_console start_watchdog end |
Instance Attribute Details
#logger ⇒ Object (readonly)
Returns the value of attribute logger.
20 21 22 |
# File 'lib/consolle/server/console_supervisor.rb', line 20 def logger @logger end |
#pid ⇒ Object (readonly)
Returns the value of attribute pid.
20 21 22 |
# File 'lib/consolle/server/console_supervisor.rb', line 20 def pid @pid end |
#rails_env ⇒ Object (readonly)
Returns the value of attribute rails_env.
20 21 22 |
# File 'lib/consolle/server/console_supervisor.rb', line 20 def rails_env @rails_env end |
#rails_root ⇒ Object (readonly)
Returns the value of attribute rails_root.
20 21 22 |
# File 'lib/consolle/server/console_supervisor.rb', line 20 def rails_root @rails_root end |
#reader ⇒ Object (readonly)
Returns the value of attribute reader.
20 21 22 |
# File 'lib/consolle/server/console_supervisor.rb', line 20 def reader @reader end |
#writer ⇒ Object (readonly)
Returns the value of attribute writer.
20 21 22 |
# File 'lib/consolle/server/console_supervisor.rb', line 20 def writer @writer end |
Instance Method Details
#eval(code, timeout: nil, pre_sigint: nil) ⇒ Object
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 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 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 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 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 |
# File 'lib/consolle/server/console_supervisor.rb', line 50 def eval(code, timeout: nil, pre_sigint: nil) # CONSOLLE_TIMEOUT has highest priority if present and > 0 env_timeout = ENV['CONSOLLE_TIMEOUT']&.to_i timeout = if env_timeout && env_timeout > 0 env_timeout else timeout || 60 end @mutex.synchronize do raise 'Console is not running' unless running? # Record start time for execution measurement start_time = Time.now # Check if this is a remote console is_remote = @command.include?('ssh') || @command.include?('kamal') || @command.include?('docker') # Decide whether to send pre-exec Ctrl-C # Default: enabled for all consoles to avoid getting stuck mid-input. # Opt-out via param or ENV `CONSOLLE_DISABLE_PRE_SIGINT=1`. disable_pre_sigint = ENV['CONSOLLE_DISABLE_PRE_SIGINT'] == '1' default_pre_sigint = (@rails_env != 'test') do_pre_sigint = if pre_sigint.nil? default_pre_sigint && !disable_pre_sigint else pre_sigint end if do_pre_sigint # Send Ctrl-C to ensure clean state before execution, then wait up to 3s for prompt. # If prompt doesn't come back, consider server unhealthy, force-restart the subprocess, # and return an error so the caller can retry after recovery. @writer.write(CTRL_C) @writer.flush # Nudge IRB and force prompt emission with a trivial probe @writer.puts "puts '__consolle_probe__'" @writer.flush begin wait_for_prompt(timeout: 3, consume_all: true) rescue Timeout::Error logger.error '[ConsoleSupervisor] No prompt after pre-exec Ctrl-C (3s). Forcing console restart.' # Forcefully stop subprocess so watchdog can restart @process_mutex.synchronize do stop_console end # Return an unhealthy error to the caller err = Consolle::Errors::ServerUnhealthy.new('No prompt after pre-exec interrupt (3s); console restarted') return build_error_response(err, execution_time: 0) end else # For local consoles without pre-sigint, clear buffer only clear_buffer end # Encode code using Base64 to handle special characters and remote consoles # Ensure UTF-8 encoding to handle strings that may be tagged as ASCII-8BIT utf8_code = if code.encoding == Encoding::UTF_8 code else # Try to handle ASCII-8BIT strings that might contain UTF-8 data temp = code.dup if code.encoding == Encoding::ASCII_8BIT # First try to interpret as UTF-8 temp.force_encoding('UTF-8') if temp.valid_encoding? temp else # If not valid UTF-8, try other common encodings # or use replacement characters code.encode('UTF-8', invalid: :replace, undef: :replace) end else # For other encodings, convert to UTF-8 code.encode('UTF-8') end end # For large code, use temporary file approach if utf8_code.bytesize > 1000 logger.debug "[ConsoleSupervisor] Large code (#{utf8_code.bytesize} bytes), using temporary file approach" # Create temp file with unique name require 'tempfile' require 'securerandom' temp_filename = "consolle_temp_#{SecureRandom.hex(8)}.rb" temp_path = if defined?(Rails) && Rails.root Rails.root.join('tmp', temp_filename).to_s else File.join(Dir.tmpdir, temp_filename) end # Write code to temp file File.write(temp_path, utf8_code) logger.debug "[ConsoleSupervisor] Wrote code to temp file: #{temp_path}" # Load and execute the file with timeout eval_command = <<~RUBY.strip begin require 'timeout' _temp_file = '#{temp_path}' Timeout.timeout(#{timeout - 1}) do load _temp_file end rescue Timeout::Error => e puts "Timeout::Error: Code execution timed out after #{timeout - 1} seconds" nil rescue Exception => e puts "\#{e.class}: \#{e.message}" puts e.backtrace.first(5).join("\\n") if e.backtrace nil ensure File.unlink(_temp_file) if File.exist?(_temp_file) end RUBY @writer.puts eval_command @writer.flush else # For smaller code, use Base64 encoding to avoid escaping issues encoded_code = Base64.strict_encode64(utf8_code) eval_command = "begin; require 'timeout'; Timeout.timeout(#{timeout - 1}) { eval(Base64.decode64('#{encoded_code}').force_encoding('UTF-8'), IRB.CurrentContext.workspace.binding) }; rescue Timeout::Error => e; puts \"Timeout::Error: Code execution timed out after #{timeout - 1} seconds\"; nil; rescue Exception => e; puts \"\#{e.class}: \#{e.message}\"; nil; end" logger.debug "[ConsoleSupervisor] Small code (#{encoded_code.bytesize} bytes), using direct Base64 approach" @writer.puts eval_command @writer.flush end logger.debug "[ConsoleSupervisor] Code preview (first 100 chars): #{utf8_code[0..100].inspect}" if ENV['DEBUG'] logger.debug "[ConsoleSupervisor] Command sent at #{Time.now}, waiting for response..." # Collect output output = +'' deadline = Time.now + timeout begin loop do if Time.now > deadline logger.debug "[ConsoleSupervisor] Timeout reached after #{Time.now - start_time}s, output so far: #{output.bytesize} bytes" logger.debug "[ConsoleSupervisor] Output content: #{output.inspect}" if ENV['DEBUG'] # Timeout - try to interrupt current execution and recover prompt 3.times do |i| @writer.write(CTRL_C) @writer.flush logger.debug "[ConsoleSupervisor] Sent Ctrl-C (attempt #{i + 1})" begin wait_for_prompt(timeout: 1.0, consume_all: true) logger.debug '[ConsoleSupervisor] Prompt recovered after Ctrl-C' break rescue Timeout::Error # As a fallback for local consoles, send OS-level SIGINT to the subprocess unless is_remote begin Process.kill('INT', @pid) logger.warn '[ConsoleSupervisor] Sent OS-level SIGINT to subprocess' rescue StandardError => e logger.warn "[ConsoleSupervisor] Failed to send OS-level SIGINT: #{e.}" end end end end # Final cleanup clear_buffer execution_time = Time.now - start_time return build_timeout_response(timeout) end begin chunk = @reader.read_nonblock(4096) output << chunk logger.debug "[ConsoleSupervisor] Got #{chunk.bytesize} bytes, total output: #{output.bytesize} bytes" if ENV['DEBUG'] # Respond to cursor position request during command execution if chunk.include?("\e[6n") logger.debug "[ConsoleSupervisor] Detected cursor position request during eval, sending response" @writer.write("\e[1;1R") @writer.flush end # Check if we got prompt back clean = strip_ansi(output) if clean.match?(PROMPT_PATTERN) # Wait a bit for any trailing output sleep 0.1 begin output << @reader.read_nonblock(4096) rescue IO::WaitReadable, Errno::EIO # No more data end break end rescue IO::WaitReadable logger.debug "[ConsoleSupervisor] Waiting for data... (#{Time.now - start_time}s elapsed, output size: #{output.bytesize})" if ENV['DEBUG'] IO.select([@reader], nil, nil, 0.1) rescue Errno::EIO # PTY can throw EIO when no data available IO.select([@reader], nil, nil, 0.1) rescue EOFError execution_time = Time.now - start_time return build_error_response( EOFError.new('Console terminated'), execution_time: execution_time ) end end # Check if output is too large and truncate if necessary max_output_size = 100_000 # 100KB limit for output truncated = false if output.bytesize > max_output_size logger.warn "[ConsoleSupervisor] Output too large (#{output.bytesize} bytes), truncating to #{max_output_size} bytes" output = output[0...max_output_size] truncated = true end # Parse and return result parsed_result = parse_output(output, eval_command) # Log for debugging object output issues logger.debug "[ConsoleSupervisor] Raw output: #{output.inspect}" logger.debug "[ConsoleSupervisor] Parsed result: #{parsed_result.inspect}" # Calculate execution time execution_time = Time.now - start_time # Check if the output contains an error if parsed_result.is_a?(Hash) && parsed_result[:error] build_error_response(parsed_result[:exception], execution_time: execution_time) else result = { success: true, output: parsed_result, execution_time: execution_time } result[:truncated] = true if truncated result[:truncated_at] = max_output_size if truncated result end rescue StandardError => e logger.error "[ConsoleSupervisor] Eval error: #{e.}" execution_time = Time.now - start_time build_error_response(e, execution_time: execution_time) end end end |
#restart ⇒ Object
318 319 320 321 322 323 324 325 326 327 |
# File 'lib/consolle/server/console_supervisor.rb', line 318 def restart logger.info '[ConsoleSupervisor] Restarting Rails console subprocess...' @process_mutex.synchronize do stop_console spawn_console end @pid end |
#running? ⇒ Boolean
292 293 294 295 296 297 298 299 300 301 |
# File 'lib/consolle/server/console_supervisor.rb', line 292 def running? return false unless @pid begin Process.kill(0, @pid) true rescue Errno::ESRCH false end end |
#stop ⇒ Object
303 304 305 306 307 308 309 310 311 312 313 314 315 316 |
# File 'lib/consolle/server/console_supervisor.rb', line 303 def stop @running = false # Stop watchdog first to prevent it from restarting the process @watchdog_thread&.kill @watchdog_thread&.join(1) # Use process mutex for clean shutdown @process_mutex.synchronize do stop_console end logger.info '[ConsoleSupervisor] Stopped' end |