consolle 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pty"
4
- require "timeout"
5
- require "fcntl"
6
- require "logger"
3
+ require 'pty'
4
+ require 'timeout'
5
+ require 'fcntl'
6
+ require 'logger'
7
7
 
8
8
  # Ruby 3.4.0+ extracts base64 as a default gem
9
9
  # Suppress warning by silencing verbose mode temporarily
10
10
  original_verbose = $VERBOSE
11
11
  $VERBOSE = nil
12
- require "base64"
12
+ require 'base64'
13
13
  $VERBOSE = original_verbose
14
14
 
15
15
  module Consolle
@@ -19,17 +19,17 @@ module Consolle
19
19
 
20
20
  RESTART_DELAY = 1 # seconds
21
21
  MAX_RESTARTS = 5 # within 5 minutes
22
- RESTART_WINDOW = 300 # 5 minutes
22
+ RESTART_WINDOW = 300 # 5 minutes
23
23
  # Match various Rails console prompts
24
24
  # Match various console prompts: custom sentinel, Rails app prompts, IRB prompts, and generic prompts
25
25
  # Allow optional non-word characters before the prompt (e.g., Unicode symbols like ▽)
26
- PROMPT_PATTERN = /^[^\w]*(\u001E\u001F<CONSOLLE>\u001F\u001E|\w+[-_]?\w*\([^)]*\)>|irb\([^)]+\):[\d]+:?[\d]*[>*]|>>|>)\s*$/
26
+ PROMPT_PATTERN = /^[^\w]*(\u001E\u001F<CONSOLLE>\u001F\u001E|\w+[-_]?\w*\([^)]*\)>|irb\([^)]+\):\d+:?\d*[>*]|>>|>)\s*$/
27
27
  CTRL_C = "\x03"
28
28
 
29
- def initialize(rails_root:, rails_env: "development", logger: nil, command: nil)
29
+ def initialize(rails_root:, rails_env: 'development', logger: nil, command: nil)
30
30
  @rails_root = rails_root
31
31
  @rails_env = rails_env
32
- @command = command || "bin/rails console"
32
+ @command = command || 'bin/rails console'
33
33
  @logger = logger || Logger.new(STDOUT)
34
34
  @pid = nil
35
35
  @reader = nil
@@ -38,27 +38,27 @@ module Consolle
38
38
  @restart_timestamps = []
39
39
  @watchdog_thread = nil
40
40
  @mutex = Mutex.new
41
- @process_mutex = Mutex.new # Separate mutex for process lifecycle management
42
-
41
+ @process_mutex = Mutex.new # Separate mutex for process lifecycle management
42
+
43
43
  spawn_console
44
44
  start_watchdog
45
45
  end
46
46
 
47
47
  def eval(code, timeout: 30)
48
48
  @mutex.synchronize do
49
- raise "Console is not running" unless running?
50
-
49
+ raise 'Console is not running' unless running?
50
+
51
51
  # Check if this is a remote console
52
- is_remote = @command.include?("ssh") || @command.include?("kamal") || @command.include?("docker")
53
-
52
+ is_remote = @command.include?('ssh') || @command.include?('kamal') || @command.include?('docker')
53
+
54
54
  if is_remote
55
55
  # Send Ctrl-C to ensure clean state before execution
56
56
  @writer.write(CTRL_C)
57
57
  @writer.flush
58
-
58
+
59
59
  # Clear any pending output
60
60
  clear_buffer
61
-
61
+
62
62
  # Wait for prompt after Ctrl-C
63
63
  begin
64
64
  wait_for_prompt(timeout: 1, consume_all: true)
@@ -69,22 +69,22 @@ module Consolle
69
69
  # For local consoles, just clear buffer
70
70
  clear_buffer
71
71
  end
72
-
72
+
73
73
  # Encode code using Base64 to handle special characters and remote consoles
74
74
  # Ensure UTF-8 encoding to handle strings that may be tagged as ASCII-8BIT
75
75
  utf8_code = code.encoding == Encoding::UTF_8 ? code : code.dup.force_encoding('UTF-8')
76
76
  encoded_code = Base64.strict_encode64(utf8_code)
77
-
77
+
78
78
  # Use eval to execute the Base64-decoded code
79
79
  eval_command = "eval(Base64.decode64('#{encoded_code}'), IRB.CurrentContext.workspace.binding)"
80
- logger.debug "[ConsoleSupervisor] Sending eval command (Base64 encoded)"
80
+ logger.debug '[ConsoleSupervisor] Sending eval command (Base64 encoded)'
81
81
  @writer.puts eval_command
82
82
  @writer.flush
83
-
83
+
84
84
  # Collect output
85
- output = +""
85
+ output = +''
86
86
  deadline = Time.now + timeout
87
-
87
+
88
88
  begin
89
89
  loop do
90
90
  if Time.now > deadline
@@ -93,17 +93,17 @@ module Consolle
93
93
  @writer.flush
94
94
  sleep 0.5
95
95
  clear_buffer
96
- return {
97
- success: false,
96
+ return {
97
+ success: false,
98
98
  output: "Execution timed out after #{timeout} seconds",
99
- execution_time: timeout
99
+ execution_time: timeout
100
100
  }
101
101
  end
102
-
102
+
103
103
  begin
104
104
  chunk = @reader.read_nonblock(4096)
105
105
  output << chunk
106
-
106
+
107
107
  # Check if we got prompt back
108
108
  clean = strip_ansi(output)
109
109
  if clean.match?(PROMPT_PATTERN)
@@ -122,21 +122,21 @@ module Consolle
122
122
  # PTY can throw EIO when no data available
123
123
  IO.select([@reader], nil, nil, 0.1)
124
124
  rescue EOFError
125
- return {
126
- success: false,
127
- output: "Console terminated",
128
- execution_time: nil
125
+ return {
126
+ success: false,
127
+ output: 'Console terminated',
128
+ execution_time: nil
129
129
  }
130
130
  end
131
131
  end
132
-
132
+
133
133
  # Parse and return result
134
134
  result = parse_output(output, eval_command)
135
-
135
+
136
136
  # Log for debugging object output issues
137
137
  logger.debug "[ConsoleSupervisor] Raw output: #{output.inspect}"
138
138
  logger.debug "[ConsoleSupervisor] Parsed result: #{result.inspect}"
139
-
139
+
140
140
  { success: true, output: result, execution_time: nil }
141
141
  rescue StandardError => e
142
142
  logger.error "[ConsoleSupervisor] Eval error: #{e.message}"
@@ -147,7 +147,7 @@ module Consolle
147
147
 
148
148
  def running?
149
149
  return false unless @pid
150
-
150
+
151
151
  begin
152
152
  Process.kill(0, @pid)
153
153
  true
@@ -158,27 +158,27 @@ module Consolle
158
158
 
159
159
  def stop
160
160
  @running = false
161
-
161
+
162
162
  # Stop watchdog first to prevent it from restarting the process
163
163
  @watchdog_thread&.kill
164
164
  @watchdog_thread&.join(1)
165
-
165
+
166
166
  # Use process mutex for clean shutdown
167
167
  @process_mutex.synchronize do
168
168
  stop_console
169
169
  end
170
-
171
- logger.info "[ConsoleSupervisor] Stopped"
170
+
171
+ logger.info '[ConsoleSupervisor] Stopped'
172
172
  end
173
173
 
174
174
  def restart
175
- logger.info "[ConsoleSupervisor] Restarting Rails console subprocess..."
176
-
175
+ logger.info '[ConsoleSupervisor] Restarting Rails console subprocess...'
176
+
177
177
  @process_mutex.synchronize do
178
178
  stop_console
179
179
  spawn_console
180
180
  end
181
-
181
+
182
182
  @pid
183
183
  end
184
184
 
@@ -186,77 +186,77 @@ module Consolle
186
186
 
187
187
  def spawn_console
188
188
  env = {
189
- "RAILS_ENV" => @rails_env,
190
-
189
+ 'RAILS_ENV' => @rails_env,
190
+
191
191
  # Skip IRB configuration file (prevent conflicts with existing settings)
192
- "IRBRC" => "skip",
193
-
192
+ 'IRBRC' => 'skip',
193
+
194
194
  # Disable pry-rails (force IRB instead of Pry)
195
- "DISABLE_PRY_RAILS" => "1",
196
-
195
+ 'DISABLE_PRY_RAILS' => '1',
196
+
197
197
  # Completely disable pager
198
- "PAGER" => "cat", # Set pager to cat (immediate output)
199
- "NO_PAGER" => "1", # Pager disable flag
200
- "LESS" => "", # Clear less pager options
201
-
198
+ 'PAGER' => 'cat', # Set pager to cat (immediate output)
199
+ 'NO_PAGER' => '1', # Pager disable flag
200
+ 'LESS' => '', # Clear less pager options
201
+
202
202
  # Terminal settings (minimal features only)
203
- "TERM" => "dumb", # Set to dumb terminal (minimize color/pager features)
204
-
203
+ 'TERM' => 'dumb', # Set to dumb terminal (minimize color/pager features)
204
+
205
205
  # Disable Rails/ActiveSupport log colors
206
- "FORCE_COLOR" => "0", # Force disable colors
207
- "NO_COLOR" => "1", # Completely disable color output
208
-
206
+ 'FORCE_COLOR' => '0', # Force disable colors
207
+ 'NO_COLOR' => '1', # Completely disable color output
208
+
209
209
  # Disable other interactive features
210
- "COLUMNS" => "120", # Fixed column count (prevent auto-detection)
211
- "LINES" => "24" # Fixed line count (prevent auto-detection)
210
+ 'COLUMNS' => '120', # Fixed column count (prevent auto-detection)
211
+ 'LINES' => '24' # Fixed line count (prevent auto-detection)
212
212
  }
213
213
  logger.info "[ConsoleSupervisor] Spawning console with command: #{@command} (#{@rails_env})"
214
-
214
+
215
215
  @reader, @writer, @pid = PTY.spawn(env, @command, chdir: @rails_root)
216
-
216
+
217
217
  # Non-blocking mode
218
218
  @reader.sync = @writer.sync = true
219
219
  flags = @reader.fcntl(Fcntl::F_GETFL, 0)
220
220
  @reader.fcntl(Fcntl::F_SETFL, flags | Fcntl::O_NONBLOCK)
221
-
221
+
222
222
  @running = true
223
-
223
+
224
224
  # Record restart timestamp
225
225
  @restart_timestamps << Time.now
226
226
  trim_restart_history
227
-
227
+
228
228
  # Wait for initial prompt
229
229
  wait_for_prompt(timeout: 15)
230
-
230
+
231
231
  # Configure IRB settings for automation
232
232
  configure_irb_for_automation
233
-
233
+
234
234
  # For remote consoles (like kamal), we need more aggressive initialization
235
235
  # Check if this looks like a remote console based on the command
236
- is_remote = @command.include?("ssh") || @command.include?("kamal") || @command.include?("docker")
237
-
236
+ is_remote = @command.include?('ssh') || @command.include?('kamal') || @command.include?('docker')
237
+
238
238
  if is_remote
239
239
  # Send Ctrl-C to ensure clean state
240
240
  @writer.write(CTRL_C)
241
241
  @writer.flush
242
-
242
+
243
243
  # Wait for prompt after Ctrl-C
244
244
  begin
245
245
  wait_for_prompt(timeout: 2, consume_all: true)
246
246
  rescue Timeout::Error
247
- logger.warn "[ConsoleSupervisor] No prompt after Ctrl-C, continuing anyway"
247
+ logger.warn '[ConsoleSupervisor] No prompt after Ctrl-C, continuing anyway'
248
248
  end
249
-
249
+
250
250
  # Send a unique marker command to ensure all initialization output is consumed
251
251
  marker = "__consolle_init_#{Time.now.to_f}__"
252
252
  @writer.puts "puts '#{marker}'"
253
253
  @writer.flush
254
-
254
+
255
255
  # Read until we see our marker
256
- output = +""
256
+ output = +''
257
257
  deadline = Time.now + 3
258
258
  marker_found = false
259
-
259
+
260
260
  while Time.now < deadline && !marker_found
261
261
  begin
262
262
  chunk = @reader.read_nonblock(4096)
@@ -268,11 +268,9 @@ module Consolle
268
268
  IO.select([@reader], nil, nil, 0.1)
269
269
  end
270
270
  end
271
-
272
- unless marker_found
273
- logger.warn "[ConsoleSupervisor] Initialization marker not found, continuing anyway"
274
- end
275
-
271
+
272
+ logger.warn '[ConsoleSupervisor] Initialization marker not found, continuing anyway' unless marker_found
273
+
276
274
  # Final cleanup for remote consoles
277
275
  @writer.write(CTRL_C)
278
276
  @writer.flush
@@ -281,7 +279,7 @@ module Consolle
281
279
  # For local consoles, minimal cleanup is sufficient
282
280
  clear_buffer
283
281
  end
284
-
282
+
285
283
  logger.info "[ConsoleSupervisor] Rails console started (PID: #{@pid})"
286
284
  rescue StandardError => e
287
285
  logger.error "[ConsoleSupervisor] Failed to spawn console: #{e.message}"
@@ -290,33 +288,35 @@ module Consolle
290
288
 
291
289
  def start_watchdog
292
290
  @watchdog_thread = Thread.new do
293
- Thread.current[:consolle_watchdog] = true # Tag thread for test cleanup
291
+ Thread.current[:consolle_watchdog] = true # Tag thread for test cleanup
294
292
  while @running
295
293
  begin
296
294
  sleep 0.5
297
-
295
+
298
296
  # Use process mutex to avoid race conditions with restart
299
297
  @process_mutex.synchronize do
300
298
  # Check if process is still alive
301
- dead_pid = Process.waitpid(@pid, Process::WNOHANG) rescue nil
302
-
303
- if dead_pid || !running?
304
- if @running # Only restart if we're supposed to be running
305
- logger.warn "[ConsoleSupervisor] Console process died (PID: #{@pid}), restarting..."
306
-
307
- # Wait before restart
308
- sleep RESTART_DELAY
309
-
310
- # Respawn
311
- spawn_console
312
- end
299
+ dead_pid = begin
300
+ Process.waitpid(@pid, Process::WNOHANG)
301
+ rescue StandardError
302
+ nil
303
+ end
304
+
305
+ if (dead_pid || !running?) && @running # Only restart if we're supposed to be running
306
+ logger.warn "[ConsoleSupervisor] Console process died (PID: #{@pid}), restarting..."
307
+
308
+ # Wait before restart
309
+ sleep RESTART_DELAY
310
+
311
+ # Respawn
312
+ spawn_console
313
313
  end
314
314
  end
315
315
  rescue Errno::ECHILD
316
316
  # Process already reaped
317
317
  @process_mutex.synchronize do
318
318
  if @running
319
- logger.warn "[ConsoleSupervisor] Console process missing, restarting..."
319
+ logger.warn '[ConsoleSupervisor] Console process missing, restarting...'
320
320
  sleep RESTART_DELAY
321
321
  spawn_console
322
322
  end
@@ -325,45 +325,45 @@ module Consolle
325
325
  logger.error "[ConsoleSupervisor] Watchdog error: #{e.message}"
326
326
  end
327
327
  end
328
-
329
- logger.info "[ConsoleSupervisor] Watchdog thread stopped"
328
+
329
+ logger.info '[ConsoleSupervisor] Watchdog thread stopped'
330
330
  end
331
331
  end
332
332
 
333
333
  def wait_for_prompt(timeout: 15, consume_all: false)
334
- output = +""
334
+ output = +''
335
335
  deadline = Time.now + timeout
336
336
  prompt_found = false
337
337
  last_data_time = Time.now
338
-
338
+
339
339
  loop do
340
340
  if Time.now > deadline
341
341
  logger.error "[ConsoleSupervisor] Output so far: #{output.inspect}"
342
342
  logger.error "[ConsoleSupervisor] Stripped: #{strip_ansi(output).inspect}"
343
343
  raise Timeout::Error, "No prompt after #{timeout} seconds"
344
344
  end
345
-
345
+
346
346
  # If we found prompt and consume_all is true, continue reading for a bit more
347
347
  if prompt_found && consume_all
348
348
  if Time.now - last_data_time > 0.5
349
- logger.info "[ConsoleSupervisor] No more data for 0.5s after prompt, stopping"
349
+ logger.info '[ConsoleSupervisor] No more data for 0.5s after prompt, stopping'
350
350
  return true
351
351
  end
352
352
  elsif prompt_found
353
353
  return true
354
354
  end
355
-
355
+
356
356
  begin
357
357
  chunk = @reader.read_nonblock(4096)
358
358
  output << chunk
359
359
  last_data_time = Time.now
360
360
  logger.debug "[ConsoleSupervisor] Got chunk: #{chunk.inspect}"
361
-
361
+
362
362
  clean = strip_ansi(output)
363
363
  # Check each line for prompt pattern
364
364
  clean.lines.each do |line|
365
365
  if line.match?(PROMPT_PATTERN)
366
- logger.info "[ConsoleSupervisor] Found prompt!"
366
+ logger.info '[ConsoleSupervisor] Found prompt!'
367
367
  prompt_found = true
368
368
  end
369
369
  end
@@ -386,49 +386,47 @@ module Consolle
386
386
  rescue IO::WaitReadable, Errno::EIO
387
387
  # Buffer cleared for this iteration
388
388
  end
389
- sleep 0.05 if i < 2 # Sleep between iterations except the last
389
+ sleep 0.05 if i < 2 # Sleep between iterations except the last
390
390
  end
391
391
  end
392
392
 
393
393
  def configure_irb_for_automation
394
394
  # Create the invisible-wrapper sentinel prompt
395
395
  sentinel_prompt = "\u001E\u001F<CONSOLLE>\u001F\u001E "
396
-
396
+
397
397
  # Send IRB configuration commands to disable interactive features
398
398
  irb_commands = [
399
399
  # Configure custom prompt mode to eliminate continuation prompts
400
- "IRB.conf[:PROMPT][:CONSOLLE] = { " \
401
- "AUTO_INDENT: false, " \
400
+ 'IRB.conf[:PROMPT][:CONSOLLE] = { ' \
401
+ 'AUTO_INDENT: false, ' \
402
402
  "PROMPT_I: #{sentinel_prompt.inspect}, " \
403
403
  "PROMPT_N: '', " \
404
404
  "PROMPT_S: '', " \
405
405
  "PROMPT_C: '', " \
406
- "RETURN: \"=> %s\\n\" }",
407
- "IRB.conf[:PROMPT_MODE] = :CONSOLLE",
408
-
406
+ 'RETURN: "=> %s\\n" }',
407
+ 'IRB.conf[:PROMPT_MODE] = :CONSOLLE',
408
+
409
409
  # Disable interactive features
410
- "IRB.conf[:USE_PAGER] = false", # Disable pager
411
- "IRB.conf[:USE_COLORIZE] = false", # Disable color output
412
- "IRB.conf[:USE_AUTOCOMPLETE] = false", # Disable autocompletion
413
- "IRB.conf[:USE_MULTILINE] = false", # Disable multiline editor to process code at once
414
- "ActiveSupport::LogSubscriber.colorize_logging = false if defined?(ActiveSupport::LogSubscriber)" # Disable Rails logging colors
410
+ 'IRB.conf[:USE_PAGER] = false', # Disable pager
411
+ 'IRB.conf[:USE_COLORIZE] = false', # Disable color output
412
+ 'IRB.conf[:USE_AUTOCOMPLETE] = false', # Disable autocompletion
413
+ 'IRB.conf[:USE_MULTILINE] = false', # Disable multiline editor to process code at once
414
+ 'ActiveSupport::LogSubscriber.colorize_logging = false if defined?(ActiveSupport::LogSubscriber)' # Disable Rails logging colors
415
415
  ]
416
-
416
+
417
417
  irb_commands.each do |cmd|
418
- begin
419
- @writer.puts cmd
420
- @writer.flush
421
-
422
- # Wait briefly for command to execute
423
- sleep 0.1
424
-
425
- # Clear any output from the configuration command (these commands typically don't produce visible output)
426
- clear_buffer
427
- rescue StandardError => e
428
- logger.warn "[ConsoleSupervisor] Failed to configure IRB setting: #{cmd} - #{e.message}"
429
- end
418
+ @writer.puts cmd
419
+ @writer.flush
420
+
421
+ # Wait briefly for command to execute
422
+ sleep 0.1
423
+
424
+ # Clear any output from the configuration command (these commands typically don't produce visible output)
425
+ clear_buffer
426
+ rescue StandardError => e
427
+ logger.warn "[ConsoleSupervisor] Failed to configure IRB setting: #{cmd} - #{e.message}"
430
428
  end
431
-
429
+
432
430
  # Send multiple empty lines to ensure all settings are processed
433
431
  # This is especially important for remote consoles like kamal console
434
432
  2.times do
@@ -436,121 +434,143 @@ module Consolle
436
434
  @writer.flush
437
435
  sleep 0.05
438
436
  end
439
-
437
+
440
438
  # Clear buffer again after sending empty lines
441
439
  clear_buffer
442
-
440
+
443
441
  # Wait for prompt after configuration with reasonable timeout
444
442
  begin
445
443
  wait_for_prompt(timeout: 2, consume_all: false)
446
444
  rescue Timeout::Error
447
445
  # This can fail with some console types, but that's okay
448
- logger.debug "[ConsoleSupervisor] No prompt after IRB configuration, continuing"
446
+ logger.debug '[ConsoleSupervisor] No prompt after IRB configuration, continuing'
449
447
  end
450
-
448
+
451
449
  # Final buffer clear
452
450
  clear_buffer
453
-
454
- logger.debug "[ConsoleSupervisor] IRB configured for automation"
451
+
452
+ logger.debug '[ConsoleSupervisor] IRB configured for automation'
455
453
  end
456
454
 
457
455
  def strip_ansi(text)
458
456
  # Remove all ANSI escape sequences
459
457
  text
460
- .gsub(/\e\[[\d;]*[a-zA-Z]/, "") # Standard ANSI codes
461
- .gsub(/\e\[\?[\d]+[hl]/, "") # Private mode codes like [?2004h
462
- .gsub(/\e[<>=]/, "") # Other escape sequences
463
- .gsub(/[\x00-\x08\x0B-\x0C\x0E-\x1D\x7F]/, "") # Control chars except \t(09) \n(0A) \r(0D) \u001E(1E) \u001F(1F)
464
- .gsub(/\r\n/, "\n") # Normalize line endings
458
+ .gsub(/\e\[[\d;]*[a-zA-Z]/, '') # Standard ANSI codes
459
+ .gsub(/\e\[\?\d+[hl]/, '') # Private mode codes like [?2004h
460
+ .gsub(/\e[<>=]/, '') # Other escape sequences
461
+ .gsub(/[\x00-\x08\x0B-\x0C\x0E-\x1D\x7F]/, '') # Control chars except \t(09) \n(0A) \r(0D) \u001E(1E) \u001F(1F)
462
+ .gsub(/\r\n/, "\n") # Normalize line endings
465
463
  end
466
-
467
464
 
468
- def parse_output(output, code)
465
+ def parse_output(output, _code)
469
466
  # Remove ANSI codes
470
467
  clean = strip_ansi(output)
471
-
468
+
472
469
  # Split into lines
473
470
  lines = clean.lines
474
471
  result_lines = []
475
472
  skip_echo = true
476
-
473
+
477
474
  lines.each_with_index do |line, idx|
478
475
  # Skip the eval command echo (both file-based and Base64)
479
- if skip_echo && (line.include?("eval(File.read") || line.include?("eval(Base64.decode64"))
476
+ if skip_echo && (line.include?('eval(File.read') || line.include?('eval(Base64.decode64'))
480
477
  skip_echo = false
481
478
  next
482
479
  end
483
-
480
+
484
481
  # Skip prompts (but not return values that start with =>)
485
- if line.match?(PROMPT_PATTERN) && !line.start_with?("=>")
486
- next
487
- end
488
-
482
+ next if line.match?(PROMPT_PATTERN) && !line.start_with?('=>')
483
+
489
484
  # Skip common IRB configuration output patterns
490
485
  if line.match?(/^(IRB\.conf|DISABLE_PRY_RAILS|Switch to inspect mode|Loading .*\.rb|nil)$/) ||
491
486
  line.match?(/^__consolle_init_[\d.]+__$/) ||
492
487
  line.match?(/^'consolle_init'$/) ||
493
- line.strip == "false" && idx == 0 # Skip leading false from IRB config
488
+ line.strip == 'false' && idx == 0 # Skip leading false from IRB config
494
489
  next
495
490
  end
496
-
491
+
497
492
  # Collect all other lines (including return values and side effects)
498
493
  result_lines << line
499
494
  end
500
-
495
+
501
496
  # Join all lines - this includes both side effects and return values
502
- result = result_lines.join.strip
503
-
504
- result
497
+ result_lines.join.strip
505
498
  end
506
499
 
507
500
  def trim_restart_history
508
501
  # Keep only restarts within the window
509
502
  cutoff = Time.now - RESTART_WINDOW
510
503
  @restart_timestamps.keep_if { |t| t > cutoff }
511
-
504
+
512
505
  # Check if too many restarts
513
- if @restart_timestamps.size > MAX_RESTARTS
514
- logger.error "[ConsoleSupervisor] Too many restarts (#{@restart_timestamps.size} in #{RESTART_WINDOW}s)"
515
- # TODO: Send alert to ops team
516
- end
506
+ return unless @restart_timestamps.size > MAX_RESTARTS
507
+
508
+ logger.error "[ConsoleSupervisor] Too many restarts (#{@restart_timestamps.size} in #{RESTART_WINDOW}s)"
509
+ # TODO: Send alert to ops team
517
510
  end
518
511
 
519
512
  def stop_console
520
513
  return unless running?
521
-
514
+
522
515
  begin
523
- @writer.puts("exit")
516
+ @writer.puts('exit')
524
517
  @writer.flush
525
518
  rescue StandardError
526
519
  # PTY might be closed already
527
520
  end
528
-
521
+
529
522
  # Wait for process to exit gracefully
530
- waited = Process.waitpid(@pid, Process::WNOHANG) rescue nil
523
+ waited = begin
524
+ Process.waitpid(@pid, Process::WNOHANG)
525
+ rescue StandardError
526
+ nil
527
+ end
531
528
  unless waited
532
529
  # Give it up to 3 seconds to exit gracefully
533
530
  30.times do
534
531
  sleep 0.1
535
532
  break unless running?
536
- waited = Process.waitpid(@pid, Process::WNOHANG) rescue nil
533
+
534
+ waited = begin
535
+ Process.waitpid(@pid, Process::WNOHANG)
536
+ rescue StandardError
537
+ nil
538
+ end
537
539
  break if waited
538
540
  end
539
541
  end
540
-
542
+
541
543
  # Force kill if still running
542
544
  if running? && !waited
543
- Process.kill("TERM", @pid) rescue nil
545
+ begin
546
+ Process.kill('TERM', @pid)
547
+ rescue StandardError
548
+ nil
549
+ end
544
550
  sleep 0.5
545
- Process.kill("KILL", @pid) rescue nil if running?
551
+ if running?
552
+ begin
553
+ Process.kill('KILL', @pid)
554
+ rescue StandardError
555
+ nil
556
+ end
557
+ end
546
558
  end
547
559
  rescue Errno::ECHILD
548
560
  # Process already gone
549
561
  ensure
550
562
  # Close PTY file descriptors
551
- @reader&.close rescue nil
552
- @writer&.close rescue nil
563
+ begin
564
+ @reader&.close
565
+ rescue StandardError
566
+ nil
567
+ end
568
+ begin
569
+ @writer&.close
570
+ rescue StandardError
571
+ nil
572
+ end
553
573
  end
554
574
  end
555
575
  end
556
- end
576
+ end