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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/Gemfile +2 -2
- data/Gemfile.lock +1 -1
- data/bin/cone +2 -2
- data/bin/consolle +2 -2
- data/consolle.gemspec +20 -20
- data/lib/consolle/adapters/rails_console.rb +82 -77
- data/lib/consolle/cli.rb +380 -255
- data/lib/consolle/server/console_socket_server.rb +79 -72
- data/lib/consolle/server/console_supervisor.rb +194 -174
- data/lib/consolle/server/request_broker.rb +96 -99
- data/lib/consolle/version.rb +2 -2
- data/lib/consolle.rb +6 -6
- metadata +3 -3
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
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
|
|
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
|
|
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\([^)]+\)
|
|
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:
|
|
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 ||
|
|
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
|
|
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
|
|
50
|
-
|
|
49
|
+
raise 'Console is not running' unless running?
|
|
50
|
+
|
|
51
51
|
# Check if this is a remote console
|
|
52
|
-
is_remote = @command.include?(
|
|
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
|
|
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:
|
|
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
|
|
170
|
+
|
|
171
|
+
logger.info '[ConsoleSupervisor] Stopped'
|
|
172
172
|
end
|
|
173
173
|
|
|
174
174
|
def restart
|
|
175
|
-
logger.info
|
|
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
|
-
|
|
190
|
-
|
|
189
|
+
'RAILS_ENV' => @rails_env,
|
|
190
|
+
|
|
191
191
|
# Skip IRB configuration file (prevent conflicts with existing settings)
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
'IRBRC' => 'skip',
|
|
193
|
+
|
|
194
194
|
# Disable pry-rails (force IRB instead of Pry)
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
'DISABLE_PRY_RAILS' => '1',
|
|
196
|
+
|
|
197
197
|
# Completely disable pager
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
203
|
+
'TERM' => 'dumb', # Set to dumb terminal (minimize color/pager features)
|
|
204
|
+
|
|
205
205
|
# Disable Rails/ActiveSupport log colors
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
'FORCE_COLOR' => '0', # Force disable colors
|
|
207
|
+
'NO_COLOR' => '1', # Completely disable color output
|
|
208
|
+
|
|
209
209
|
# Disable other interactive features
|
|
210
|
-
|
|
211
|
-
|
|
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?(
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
406
|
+
'RETURN: "=> %s\\n" }',
|
|
407
|
+
'IRB.conf[:PROMPT_MODE] = :CONSOLLE',
|
|
408
|
+
|
|
409
409
|
# Disable interactive features
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
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
|
|
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]/,
|
|
461
|
-
.gsub(/\e\[
|
|
462
|
-
.gsub(/\e[<>=]/,
|
|
463
|
-
.gsub(/[\x00-\x08\x0B-\x0C\x0E-\x1D\x7F]/,
|
|
464
|
-
.gsub(/\r\n/, "\n")
|
|
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,
|
|
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?(
|
|
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
|
-
|
|
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 ==
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
545
|
+
begin
|
|
546
|
+
Process.kill('TERM', @pid)
|
|
547
|
+
rescue StandardError
|
|
548
|
+
nil
|
|
549
|
+
end
|
|
544
550
|
sleep 0.5
|
|
545
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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
|