Class: Consolle::Server::ConsoleSupervisor

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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

#loggerObject (readonly)

Returns the value of attribute logger.



20
21
22
# File 'lib/consolle/server/console_supervisor.rb', line 20

def logger
  @logger
end

#pidObject (readonly)

Returns the value of attribute pid.



20
21
22
# File 'lib/consolle/server/console_supervisor.rb', line 20

def pid
  @pid
end

#rails_envObject (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_rootObject (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

#readerObject (readonly)

Returns the value of attribute reader.



20
21
22
# File 'lib/consolle/server/console_supervisor.rb', line 20

def reader
  @reader
end

#writerObject (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.message}"
                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.message}"
      execution_time = Time.now - start_time
      build_error_response(e, execution_time: execution_time)
    end
  end
end

#restartObject



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

Returns:

  • (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

#stopObject



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