consolle 0.3.5 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 570ace84f57a8120067ddae2ce8782e530f7140984558dc1b3550614a5526225
4
- data.tar.gz: 0c35ba1f9afad687526c4e213adc81b1960374af656756cb6ac052c876a0c320
3
+ metadata.gz: a88f6019a32aaae300dee3a3e39b1dd1084ea1e2900171d6ee02f880923e071e
4
+ data.tar.gz: e9c468bc8095d8197e8f70cc4c0b75d0cc8da9ba3a7b0af45573300b755e1970
5
5
  SHA512:
6
- metadata.gz: a058e7974ec391a07b055b291d1d933dfb48d03850b29bdbbda926ea304146d6d7174764b9099acc24d48be144ac6ffa08b55d157cb714c9f42159dea7dd3a49
7
- data.tar.gz: defc74e3bc92a8bb766fcfc3b2aad0195a76b7ac4c7748507cb323a519836976e8bb8add037785ac25c35f83fb05ace9995b4e18aa0ce3ca5324a9b0f8bcccb6
6
+ metadata.gz: e5c0615769edda8936547380360ed5a7beeaaeda7569ccf91322daa4658923a82cad4ad87e5ffabeca4c230e89f3d371ebcaa5eec58663b25172ee0ab8c2ba07
7
+ data.tar.gz: 83dd02825015e9f9e73ff48075436f459722cdc045ee4b0b8d90509c5ca91274c27af682549ef114598efcf21d90628fc460360f33a069134b0da63eeab94c81
data/.gitignore CHANGED
@@ -3,4 +3,6 @@ tmp/
3
3
  .gem/
4
4
  *.gem
5
5
  .notes/
6
- cone-examples.md
6
+ cone-examples.md
7
+ vendor/
8
+ .bundle/
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.5
1
+ 0.3.7
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- consolle (0.3.5)
4
+ consolle (0.3.7)
5
5
  logger (~> 1.0)
6
6
  thor (~> 1.0)
7
7
 
data/README.md CHANGED
@@ -8,7 +8,7 @@ Consolle is a library that manages Rails console through PTY (Pseudo-Terminal).
8
8
  - **Socket Server Architecture**: Stable client-server communication through Unix socket
9
9
  - **Automatic Restart (Watchdog)**: Automatic recovery on process failure
10
10
  - **Environment-specific Execution**: Supports Rails environments (development, test, production)
11
- - **Timeout Handling**: Automatic termination of infinite loops and long-running code
11
+ - **Timeout Handling**: Automatic termination of long-running code with robust prompt recovery
12
12
  - **Log Management**: Automatic management of execution history and session logs
13
13
 
14
14
  ## Installation
@@ -50,17 +50,46 @@ cone restart
50
50
  ### Advanced Usage
51
51
 
52
52
  ```bash
53
- # Start with specific environment
54
- cone start -e test
53
+ # Start with specific environment (use RAILS_ENV)
54
+ RAILS_ENV=test cone start
55
55
 
56
- # Restart with environment change
57
- cone restart -e production
56
+ # Restart with environment change (use RAILS_ENV)
57
+ RAILS_ENV=production cone restart
58
58
 
59
59
  # Force full server restart
60
60
  cone restart --force
61
61
 
62
- # Set timeout
63
- cone exec "sleep 10" --timeout 5
62
+ # Set timeout (default: 60s; precedence: CONSOLLE_TIMEOUT > --timeout > defaults)
63
+ CONSOLLE_TIMEOUT=90 cone exec "long_running_task" # env wins
64
+ cone exec "long_running_task" --timeout 120 # falls back when env not set
65
+
66
+ # Pre-exec Ctrl-C (prompt separation)
67
+ By default (development/production), cone sends Ctrl-C before each `exec` and waits for the IRB prompt (up to 3 seconds) to ensure a clean state and avoid hanging on partial input. If the prompt does not return within 3 seconds, the console subprocess is force-restarted and the request fails with `SERVER_UNHEALTHY`, so the caller can retry.
68
+
69
+ Timeout precedence
70
+ - `CONSOLLE_TIMEOUT` (if set and > 0) overrides all other sources on both client and server.
71
+ - Otherwise, CLI `--timeout` is used.
72
+ - Otherwise, default of 60s applies.
73
+
74
+ - Per‑call control (CLI):
75
+ - Enable: `cone exec --pre-sigint 'code'`
76
+ - Disable: `cone exec --no-pre-sigint 'code'`
77
+ - Global (server‑wide) control via environment when starting the server:
78
+ - Disable: `CONSOLLE_DISABLE_PRE_SIGINT=1 cone start`
79
+ - Note: this env var is read by the server process at start time, not by `cone exec`.
80
+
81
+ # Timeout and interrupt behavior during execution
82
+ - On execution timeout, cone sends Ctrl‑C to interrupt and attempts prompt recovery. For local consoles, it also sends an OS‑level `SIGINT` as a fallback.
83
+ - After recovery, subsequent `cone exec` requests continue normally.
84
+
85
+ # Error codes
86
+ - `EXECUTION_TIMEOUT`: The executed code exceeded its timeout.
87
+ - `SERVER_UNHEALTHY`: Pre‑exec prompt did not return within 3 seconds; the console subprocess was restarted and the request failed.
88
+
89
+ # Examples
90
+ - Force an execution timeout quickly and verify recovery:
91
+ - `cone exec 'sleep 999' --timeout 2` → fails with `EXECUTION_TIMEOUT`
92
+ - `cone exec 'puts :after_timeout; :ok'` → should succeed (`:ok`)
64
93
 
65
94
  # Verbose log output
66
95
  cone -v exec "User.all"
@@ -120,7 +149,7 @@ Consolle consists of the following structure:
120
149
  - Manages Rails console process through PTY
121
150
  - Automatic restart (Watchdog) feature
122
151
  - Environment variable settings and IRB automation configuration
123
- - Timeout handling and Ctrl-C support
152
+ - Timeout handling and Ctrl-C support (pre-exec safety check + post-timeout recovery)
124
153
 
125
154
  #### RequestBroker
126
155
  - Ensures request order through serial queue
@@ -145,6 +174,11 @@ env = {
145
174
  }
146
175
  ```
147
176
 
177
+ Additional environment variables
178
+
179
+ - `CONSOLLE_TIMEOUT`: Highest‑priority timeout (seconds). Overrides CLI `--timeout` and defaults on both client and server.
180
+ - `CONSOLLE_DISABLE_PRE_SIGINT=1`: Disable the pre‑exec Ctrl‑C prompt check at the server level (read when starting the server).
181
+
148
182
  ## File Locations
149
183
 
150
184
  - **Socket file**: `{Rails.root}/tmp/cone/cone.socket`
@@ -187,4 +221,4 @@ ls ~/.cone/sessions/
187
221
 
188
222
  - **Current version**: 0.1.0
189
223
  - **Ruby version**: 3.0 or higher
190
- - **Rails version**: 7.0 or higher
224
+ - **Rails version**: 7.0 or higher
@@ -78,7 +78,7 @@ module Consolle
78
78
  nil
79
79
  end
80
80
 
81
- def send_code(code, timeout: 30)
81
+ def send_code(code, timeout: 60)
82
82
  raise 'Console is not running' unless running?
83
83
 
84
84
  request = {
data/lib/consolle/cli.rb CHANGED
@@ -50,7 +50,7 @@ module Consolle
50
50
  shell.say
51
51
  shell.say 'EXAMPLES:', :yellow
52
52
  shell.say " cone exec 'User.count' # Execute code in default session"
53
- shell.say " cone start -t api -e production # Start production console named 'api'"
53
+ shell.say " RAILS_ENV=production cone start # Start console in production"
54
54
  shell.say " cone exec -t api 'Rails.env' # Execute code in 'api' session"
55
55
  shell.say ' cone exec -f script.rb # Execute code from file'
56
56
  shell.say
@@ -121,8 +121,8 @@ module Consolle
121
121
  The console runs as a daemon and can be accessed through the exec command.
122
122
 
123
123
  You can specify a custom session name with --target to run multiple consoles:
124
- cone start --target api --rails_env production
125
- cone start --target worker --rails_env development
124
+ RAILS_ENV=production cone start --target api
125
+ RAILS_ENV=development cone start --target worker
126
126
 
127
127
  Custom console commands are supported for special environments:
128
128
  cone start --command "kamal app exec -i 'bin/rails console'"
@@ -131,7 +131,7 @@ module Consolle
131
131
  For SSH-based commands that require authentication (e.g., 1Password SSH agent):
132
132
  cone start --command "kamal console" --wait-timeout 60
133
133
  LONGDESC
134
- method_option :rails_env, type: :string, aliases: '-e', desc: 'Rails environment', default: 'development'
134
+ # Rails environment is now controlled via RAILS_ENV, not a CLI option
135
135
  method_option :command, type: :string, aliases: '-c', desc: 'Custom console command', default: 'bin/rails console'
136
136
  method_option :wait_timeout, type: :numeric, aliases: '-w', desc: 'Timeout for console startup (seconds)', default: Consolle::DEFAULT_WAIT_TIMEOUT
137
137
  def start
@@ -160,7 +160,7 @@ module Consolle
160
160
  clear_session_info
161
161
  end
162
162
 
163
- adapter = create_rails_adapter(options[:rails_env], options[:target], options[:command], options[:wait_timeout])
163
+ adapter = create_rails_adapter(current_rails_env, options[:target], options[:command], options[:wait_timeout])
164
164
 
165
165
  puts 'Starting Rails console...'
166
166
 
@@ -175,7 +175,7 @@ module Consolle
175
175
 
176
176
  # Log session start
177
177
  log_session_event(adapter.process_pid, 'session_start', {
178
- rails_env: options[:rails_env],
178
+ rails_env: current_rails_env,
179
179
  socket_path: adapter.socket_path
180
180
  })
181
181
  rescue StandardError => e
@@ -197,7 +197,7 @@ module Consolle
197
197
  end
198
198
 
199
199
  # Check if server is actually responsive
200
- adapter = create_rails_adapter('development', options[:target])
200
+ adapter = create_rails_adapter(current_rails_env, options[:target])
201
201
  server_status = begin
202
202
  adapter.get_status
203
203
  rescue StandardError
@@ -255,7 +255,7 @@ module Consolle
255
255
  # Check if process is alive
256
256
  if info['process_pid'] && process_alive?(info['process_pid'])
257
257
  # Try to get server status
258
- adapter = create_rails_adapter('development', name)
258
+ adapter = create_rails_adapter(current_rails_env, name)
259
259
  server_status = begin
260
260
  adapter.get_status
261
261
  rescue StandardError
@@ -390,13 +390,12 @@ module Consolle
390
390
  cone restart -t api
391
391
  cone restart --target worker --force
392
392
  LONGDESC
393
- method_option :rails_env, type: :string, aliases: '-e', desc: 'Rails environment', default: 'development'
394
393
  method_option :force, type: :boolean, aliases: '-f', desc: 'Force restart the entire server'
395
394
  def restart
396
395
  ensure_rails_project!
397
396
  validate_session_name!(options[:target])
398
397
 
399
- adapter = create_rails_adapter(options[:rails_env], options[:target])
398
+ adapter = create_rails_adapter(current_rails_env, options[:target])
400
399
 
401
400
  if adapter.running?
402
401
  # Check if environment needs to be changed
@@ -406,25 +405,20 @@ module Consolle
406
405
  nil
407
406
  end
408
407
  current_env = current_status&.dig('rails_env') || 'development'
409
- needs_full_restart = options[:force] || (current_env != options[:rails_env])
408
+ desired_env = current_rails_env
409
+ needs_full_restart = options[:force] || (current_env != desired_env)
410
410
 
411
411
  if needs_full_restart
412
- if current_env != options[:rails_env]
413
- puts "Environment change detected (#{current_env} -> #{options[:rails_env]})"
412
+ if current_env != desired_env
413
+ puts "Environment change detected (#{current_env} -> #{desired_env})"
414
414
  puts 'Performing full server restart...'
415
415
  else
416
416
  puts 'Force restarting Rails console server...'
417
417
  end
418
418
 
419
- # Save current rails_env for start command
420
- old_env = @rails_env
421
- @rails_env = options[:rails_env]
422
-
423
419
  stop
424
420
  sleep 1
425
- invoke(:start, [], { rails_env: options[:rails_env] })
426
-
427
- @rails_env = old_env
421
+ invoke(:start, [], {})
428
422
  else
429
423
  puts 'Restarting Rails console subprocess...'
430
424
 
@@ -459,7 +453,7 @@ module Consolle
459
453
  end
460
454
  else
461
455
  puts 'Rails console is not running. Starting it...'
462
- invoke(:start, [], { rails_env: options[:rails_env] })
456
+ invoke(:start)
463
457
  end
464
458
  end
465
459
 
@@ -484,7 +478,8 @@ module Consolle
484
478
  #{' '}
485
479
  The console must be started first with 'cone start'.
486
480
  LONGDESC
487
- method_option :timeout, type: :numeric, desc: 'Timeout in seconds', default: 30
481
+ method_option :timeout, type: :numeric, desc: 'Timeout in seconds', default: 60
482
+ method_option :pre_sigint, type: :boolean, desc: 'Send Ctrl-C before executing code (experimental)'
488
483
  method_option :file, type: :string, aliases: '-f', desc: 'Read Ruby code from FILE'
489
484
  method_option :raw, type: :boolean, desc: 'Do not apply escape fixes for Claude Code (keep \\! as is)'
490
485
  def exec(*code_parts)
@@ -548,7 +543,13 @@ module Consolle
548
543
  puts "Executing: #{code}" if options[:verbose]
549
544
 
550
545
  # Send code to socket
551
- result = send_code_to_socket(session_info[:socket_path], code, timeout: options[:timeout])
546
+ send_opts = { timeout: options[:timeout] }
547
+ send_opts[:pre_sigint] = options[:pre_sigint] unless options[:pre_sigint].nil?
548
+ result = send_code_to_socket(
549
+ session_info[:socket_path],
550
+ code,
551
+ **send_opts
552
+ )
552
553
 
553
554
  # Log the request and response
554
555
  log_session_activity(session_info[:process_pid], code, result)
@@ -583,6 +584,10 @@ module Consolle
583
584
 
584
585
  private
585
586
 
587
+ def current_rails_env
588
+ ENV['RAILS_ENV'] || 'development'
589
+ end
590
+
586
591
  def ensure_rails_project!
587
592
  return if File.exist?('config/environment.rb') || File.exist?('config/application.rb')
588
593
 
@@ -621,21 +626,27 @@ module Consolle
621
626
  File.join(Dir.pwd, 'tmp', 'cone', "#{target}.log")
622
627
  end
623
628
 
624
- def send_code_to_socket(socket_path, code, timeout: 30)
629
+ def send_code_to_socket(socket_path, code, timeout: 60, pre_sigint: nil)
625
630
  request_id = SecureRandom.uuid
626
631
  # Ensure code is UTF-8 encoded
627
632
  code = code.force_encoding('UTF-8') if code.respond_to?(:force_encoding)
628
633
 
634
+ # CONSOLLE_TIMEOUT takes highest priority on client side if present and > 0
635
+ env_timeout = ENV['CONSOLLE_TIMEOUT']&.to_i
636
+ effective_timeout = (env_timeout && env_timeout > 0) ? env_timeout : timeout
637
+
629
638
  request = {
630
639
  'action' => 'eval',
631
640
  'code' => code,
632
- 'timeout' => timeout,
641
+ 'timeout' => effective_timeout,
633
642
  'request_id' => request_id
634
643
  }
644
+ # Include pre_sigint flag only when explicitly provided (true/false)
645
+ request['pre_sigint'] = pre_sigint unless pre_sigint.nil?
635
646
 
636
647
  STDERR.puts "[DEBUG] Creating socket connection to: #{socket_path}" if ENV['DEBUG']
637
648
 
638
- Timeout.timeout(timeout + 5) do
649
+ Timeout.timeout(effective_timeout + 5) do
639
650
  socket = UNIXSocket.new(socket_path)
640
651
  STDERR.puts "[DEBUG] Socket connected" if ENV['DEBUG']
641
652
 
@@ -680,8 +691,8 @@ module Consolle
680
691
  JSON.parse(json_line) if json_line
681
692
  end
682
693
  rescue Timeout::Error
683
- STDERR.puts "[DEBUG] Timeout occurred after #{timeout} seconds" if ENV['DEBUG']
684
- { 'success' => false, 'error' => 'Timeout', 'message' => "Request timed out after #{timeout} seconds" }
694
+ STDERR.puts "[DEBUG] Timeout occurred after #{effective_timeout} seconds" if ENV['DEBUG']
695
+ { 'success' => false, 'error' => 'Timeout', 'message' => "Request timed out after #{effective_timeout} seconds" }
685
696
  rescue StandardError => e
686
697
  STDERR.puts "[DEBUG] Error: #{e.class}: #{e.message}" if ENV['DEBUG']
687
698
  { 'success' => false, 'error' => e.class.name, 'message' => e.message }
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Consolle
6
+ # Configuration loader for .consolle.yml
7
+ class Config
8
+ CONFIG_FILENAME = '.consolle.yml'
9
+
10
+ # Default prompt pattern that matches various console prompts
11
+ # - Custom sentinel: \u001E\u001F<CONSOLLE>\u001F\u001E
12
+ # - Rails app prompts: app(env)> or app(env):001>
13
+ # - IRB prompts: irb(main):001:0> or irb(main):001>
14
+ # - Generic prompts: >> or >
15
+ DEFAULT_PROMPT_PATTERN = /^[^\w]*(\u001E\u001F<CONSOLLE>\u001F\u001E|\w+[-_]?\w*\([^)]*\)(:\d+)?>|irb\([^)]+\):\d+:?\d*[>*]|>>|>)\s*$/
16
+
17
+ attr_reader :rails_root, :prompt_pattern, :raw_prompt_pattern
18
+
19
+ def initialize(rails_root)
20
+ @rails_root = rails_root
21
+ @config = load_config
22
+ @raw_prompt_pattern = @config['prompt_pattern']
23
+ @prompt_pattern = parse_prompt_pattern(@raw_prompt_pattern)
24
+ end
25
+
26
+ def self.load(rails_root)
27
+ new(rails_root)
28
+ end
29
+
30
+ # Check if a custom prompt pattern is configured
31
+ def custom_prompt_pattern?
32
+ !@raw_prompt_pattern.nil?
33
+ end
34
+
35
+ # Get human-readable description of expected prompt patterns
36
+ def prompt_pattern_description
37
+ if custom_prompt_pattern?
38
+ "Custom pattern: #{@raw_prompt_pattern}"
39
+ else
40
+ <<~DESC.strip
41
+ Default patterns:
42
+ - app(env)> or app(env):001> (Rails console)
43
+ - irb(main):001:0> (IRB)
44
+ - >> or > (Generic)
45
+ DESC
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def config_path
52
+ File.join(@rails_root, CONFIG_FILENAME)
53
+ end
54
+
55
+ def load_config
56
+ return {} unless File.exist?(config_path)
57
+
58
+ begin
59
+ YAML.safe_load(File.read(config_path)) || {}
60
+ rescue Psych::SyntaxError => e
61
+ warn "[Consolle] Warning: Failed to parse #{CONFIG_FILENAME}: #{e.message}"
62
+ {}
63
+ end
64
+ end
65
+
66
+ def parse_prompt_pattern(pattern_string)
67
+ return DEFAULT_PROMPT_PATTERN if pattern_string.nil?
68
+
69
+ begin
70
+ Regexp.new(pattern_string)
71
+ rescue RegexpError => e
72
+ warn "[Consolle] Warning: Invalid prompt_pattern '#{pattern_string}': #{e.message}"
73
+ warn "[Consolle] Using default pattern instead."
74
+ DEFAULT_PROMPT_PATTERN
75
+ end
76
+ end
77
+ end
78
+ end
@@ -32,6 +32,63 @@ module Consolle
32
32
  # Execution errors
33
33
  class ExecutionError < Error; end
34
34
 
35
+ # Server/console health issues
36
+ class ServerUnhealthy < Error
37
+ def initialize(message = 'Console server is unhealthy')
38
+ super(message)
39
+ end
40
+ end
41
+
42
+ # Prompt detection failure with diagnostic information
43
+ class PromptDetectionError < Error
44
+ attr_reader :received_output, :expected_patterns, :config_path
45
+
46
+ def initialize(timeout:, received_output:, expected_patterns:, config_path:)
47
+ @received_output = received_output
48
+ @expected_patterns = expected_patterns
49
+ @config_path = config_path
50
+
51
+ super(build_message(timeout))
52
+ end
53
+
54
+ private
55
+
56
+ def build_message(timeout)
57
+ # Extract last line that looks like a prompt (ends with > or similar)
58
+ lines = @received_output.to_s.lines.map(&:strip).reject(&:empty?)
59
+ potential_prompt = lines.reverse.find { |l| l.match?(/[>*]\s*$/) } || lines.last
60
+
61
+ <<~MSG.strip
62
+ Prompt not detected after #{timeout} seconds
63
+
64
+ Received output:
65
+ #{@received_output.to_s.lines.last(5).map { |l| l.strip }.join("\n ")}
66
+
67
+ Potential prompt found:
68
+ #{potential_prompt.inspect}
69
+
70
+ #{@expected_patterns}
71
+
72
+ To fix this, add to #{@config_path}:
73
+ prompt_pattern: '#{escape_for_yaml(potential_prompt)}'
74
+
75
+ Or set environment variable:
76
+ CONSOLLE_PROMPT_PATTERN='#{escape_for_yaml(potential_prompt)}' cone start
77
+ MSG
78
+ end
79
+
80
+ def escape_for_yaml(str)
81
+ return '' if str.nil?
82
+ # Escape special regex characters and create a simple pattern
83
+ str.to_s
84
+ .gsub(/\e\[[\d;]*[a-zA-Z]/, '') # Remove ANSI codes
85
+ .gsub(/[\x00-\x1F]/, '') # Remove control characters
86
+ .strip
87
+ .gsub(/(\d+)/, '\d+') # Replace numbers with \d+
88
+ .gsub(/([().\[\]{}|*+?^$\\])/, '\\\\\1') # Escape regex special chars
89
+ end
90
+ end
91
+
35
92
  # Syntax error in executed code
36
93
  class SyntaxError < ExecutionError
37
94
  def initialize(message)
@@ -72,7 +129,9 @@ module Consolle
72
129
  'RuntimeError' => 'RUNTIME_ERROR',
73
130
  '::RuntimeError' => 'RUNTIME_ERROR',
74
131
  'StandardError' => 'STANDARD_ERROR',
75
- 'Exception' => 'EXCEPTION'
132
+ 'Exception' => 'EXCEPTION',
133
+ 'Consolle::Errors::ServerUnhealthy' => 'SERVER_UNHEALTHY',
134
+ 'Consolle::Errors::PromptDetectionError' => 'PROMPT_DETECTION_ERROR'
76
135
  }.freeze
77
136
 
78
137
  def self.to_code(exception)
@@ -115,4 +174,4 @@ module Consolle
115
174
  end
116
175
  end
117
176
  end
118
- end
177
+ end
@@ -5,6 +5,7 @@ require 'timeout'
5
5
  require 'fcntl'
6
6
  require 'logger'
7
7
  require_relative '../constants'
8
+ require_relative '../config'
8
9
  require_relative '../errors'
9
10
 
10
11
  # Ruby 3.4.0+ extracts base64 as a default gem
@@ -17,15 +18,13 @@ $VERBOSE = original_verbose
17
18
  module Consolle
18
19
  module Server
19
20
  class ConsoleSupervisor
20
- attr_reader :pid, :reader, :writer, :rails_root, :rails_env, :logger
21
+ attr_reader :pid, :reader, :writer, :rails_root, :rails_env, :logger, :config
21
22
 
22
23
  RESTART_DELAY = 1 # seconds
23
24
  MAX_RESTARTS = 5 # within 5 minutes
24
25
  RESTART_WINDOW = 300 # 5 minutes
25
- # Match various Rails console prompts
26
- # Match various console prompts: custom sentinel, Rails app prompts, IRB prompts, and generic prompts
27
- # Allow optional non-word characters before the prompt (e.g., Unicode symbols like ▽)
28
- PROMPT_PATTERN = /^[^\w]*(\u001E\u001F<CONSOLLE>\u001F\u001E|\w+[-_]?\w*\([^)]*\)>|irb\([^)]+\):\d+:?\d*[>*]|>>|>)\s*$/
26
+ # Legacy constant for backward compatibility - use config.prompt_pattern instead
27
+ PROMPT_PATTERN = Consolle::Config::DEFAULT_PROMPT_PATTERN
29
28
  CTRL_C = "\x03"
30
29
 
31
30
  def initialize(rails_root:, rails_env: 'development', logger: nil, command: nil, wait_timeout: nil)
@@ -34,6 +33,7 @@ module Consolle
34
33
  @command = command || 'bin/rails console'
35
34
  @logger = logger || Logger.new(STDOUT)
36
35
  @wait_timeout = wait_timeout || Consolle::DEFAULT_WAIT_TIMEOUT
36
+ @config = Consolle::Config.load(rails_root)
37
37
  @pid = nil
38
38
  @reader = nil
39
39
  @writer = nil
@@ -47,10 +47,14 @@ module Consolle
47
47
  start_watchdog
48
48
  end
49
49
 
50
- def eval(code, timeout: nil)
51
- # Allow timeout to be configured via environment variable
52
- default_timeout = ENV['CONSOLLE_TIMEOUT'] ? ENV['CONSOLLE_TIMEOUT'].to_i : 30
53
- timeout ||= default_timeout
50
+ def eval(code, timeout: nil, pre_sigint: nil)
51
+ # CONSOLLE_TIMEOUT has highest priority if present and > 0
52
+ env_timeout = ENV['CONSOLLE_TIMEOUT']&.to_i
53
+ timeout = if env_timeout && env_timeout > 0
54
+ env_timeout
55
+ else
56
+ timeout || 60
57
+ end
54
58
  @mutex.synchronize do
55
59
  raise 'Console is not running' unless running?
56
60
 
@@ -60,22 +64,40 @@ module Consolle
60
64
  # Check if this is a remote console
61
65
  is_remote = @command.include?('ssh') || @command.include?('kamal') || @command.include?('docker')
62
66
 
63
- if is_remote
64
- # Send Ctrl-C to ensure clean state before execution
67
+ # Decide whether to send pre-exec Ctrl-C
68
+ # Default: enabled for all consoles to avoid getting stuck mid-input.
69
+ # Opt-out via param or ENV `CONSOLLE_DISABLE_PRE_SIGINT=1`.
70
+ disable_pre_sigint = ENV['CONSOLLE_DISABLE_PRE_SIGINT'] == '1'
71
+ default_pre_sigint = (@rails_env != 'test')
72
+ do_pre_sigint = if pre_sigint.nil?
73
+ default_pre_sigint && !disable_pre_sigint
74
+ else
75
+ pre_sigint
76
+ end
77
+
78
+ if do_pre_sigint
79
+ # Send Ctrl-C to ensure clean state before execution, then wait up to 3s for prompt.
80
+ # If prompt doesn't come back, consider server unhealthy, force-restart the subprocess,
81
+ # and return an error so the caller can retry after recovery.
65
82
  @writer.write(CTRL_C)
66
83
  @writer.flush
67
-
68
- # Clear any pending output
69
- clear_buffer
70
-
71
- # Wait for prompt after Ctrl-C
84
+ # Nudge IRB and force prompt emission with a trivial probe
85
+ @writer.puts "puts '__consolle_probe__'"
86
+ @writer.flush
72
87
  begin
73
- wait_for_prompt(timeout: 1, consume_all: true)
88
+ wait_for_prompt(timeout: 3, consume_all: true)
74
89
  rescue Timeout::Error
75
- # Continue anyway, some consoles may not show prompt immediately
90
+ logger.error '[ConsoleSupervisor] No prompt after pre-exec Ctrl-C (3s). Forcing console restart.'
91
+ # Forcefully stop subprocess so watchdog can restart
92
+ @process_mutex.synchronize do
93
+ stop_console
94
+ end
95
+ # Return an unhealthy error to the caller
96
+ err = Consolle::Errors::ServerUnhealthy.new('No prompt after pre-exec interrupt (3s); console restarted')
97
+ return build_error_response(err, execution_time: 0)
76
98
  end
77
99
  else
78
- # For local consoles, just clear buffer
100
+ # For local consoles without pre-sigint, clear buffer only
79
101
  clear_buffer
80
102
  end
81
103
 
@@ -164,10 +186,28 @@ module Consolle
164
186
  if Time.now > deadline
165
187
  logger.debug "[ConsoleSupervisor] Timeout reached after #{Time.now - start_time}s, output so far: #{output.bytesize} bytes"
166
188
  logger.debug "[ConsoleSupervisor] Output content: #{output.inspect}" if ENV['DEBUG']
167
- # Timeout - send Ctrl-C
168
- @writer.write(CTRL_C)
169
- @writer.flush
170
- sleep 0.5
189
+ # Timeout - try to interrupt current execution and recover prompt
190
+ 3.times do |i|
191
+ @writer.write(CTRL_C)
192
+ @writer.flush
193
+ logger.debug "[ConsoleSupervisor] Sent Ctrl-C (attempt #{i + 1})"
194
+ begin
195
+ wait_for_prompt(timeout: 1.0, consume_all: true)
196
+ logger.debug '[ConsoleSupervisor] Prompt recovered after Ctrl-C'
197
+ break
198
+ rescue Timeout::Error
199
+ # As a fallback for local consoles, send OS-level SIGINT to the subprocess
200
+ unless is_remote
201
+ begin
202
+ Process.kill('INT', @pid)
203
+ logger.warn '[ConsoleSupervisor] Sent OS-level SIGINT to subprocess'
204
+ rescue StandardError => e
205
+ logger.warn "[ConsoleSupervisor] Failed to send OS-level SIGINT: #{e.message}"
206
+ end
207
+ end
208
+ end
209
+ end
210
+ # Final cleanup
171
211
  clear_buffer
172
212
  execution_time = Time.now - start_time
173
213
  return build_timeout_response(timeout)
@@ -187,7 +227,7 @@ module Consolle
187
227
 
188
228
  # Check if we got prompt back
189
229
  clean = strip_ansi(output)
190
- if clean.match?(PROMPT_PATTERN)
230
+ if clean.match?(prompt_pattern)
191
231
  # Wait a bit for any trailing output
192
232
  sleep 0.1
193
233
  begin
@@ -260,6 +300,11 @@ module Consolle
260
300
  end
261
301
  end
262
302
 
303
+ # Returns the prompt pattern to use (custom from config or default)
304
+ def prompt_pattern
305
+ @config&.prompt_pattern || Consolle::Config::DEFAULT_PROMPT_PATTERN
306
+ end
307
+
263
308
  def stop
264
309
  @running = false
265
310
 
@@ -491,7 +536,16 @@ module Consolle
491
536
  logger.error "[ConsoleSupervisor] Timeout reached. Current: #{current_time}, Deadline: #{deadline}, Elapsed: #{current_time - start_time}s"
492
537
  logger.error "[ConsoleSupervisor] Output so far: #{output.inspect}"
493
538
  logger.error "[ConsoleSupervisor] Stripped: #{strip_ansi(output).inspect}"
494
- raise Timeout::Error, "No prompt after #{timeout} seconds"
539
+
540
+ # Raise PromptDetectionError with diagnostic information
541
+ config_path = File.join(@rails_root || '.', Consolle::Config::CONFIG_FILENAME)
542
+ expected_desc = @config&.prompt_pattern_description || "Default prompt patterns"
543
+ raise Consolle::Errors::PromptDetectionError.new(
544
+ timeout: timeout,
545
+ received_output: strip_ansi(output),
546
+ expected_patterns: expected_desc,
547
+ config_path: config_path
548
+ )
495
549
  end
496
550
 
497
551
  # If we found prompt and consume_all is true, continue reading for a bit more
@@ -520,7 +574,7 @@ module Consolle
520
574
  clean = strip_ansi(output)
521
575
  # Check each line for prompt pattern
522
576
  clean.lines.each do |line|
523
- if line.match?(PROMPT_PATTERN)
577
+ if line.match?(prompt_pattern)
524
578
  logger.info '[ConsoleSupervisor] Found prompt!'
525
579
  prompt_found = true
526
580
  end
@@ -607,7 +661,7 @@ module Consolle
607
661
  # Wait for prompt after configuration with reasonable timeout
608
662
  begin
609
663
  wait_for_prompt(timeout: 2, consume_all: false)
610
- rescue Timeout::Error
664
+ rescue Timeout::Error, Consolle::Errors::PromptDetectionError
611
665
  # This can fail with some console types, but that's okay
612
666
  logger.debug '[ConsoleSupervisor] No prompt after IRB configuration, continuing'
613
667
  end
@@ -646,7 +700,7 @@ module Consolle
646
700
  end
647
701
 
648
702
  # Skip prompts (but not return values that start with =>)
649
- next if line.match?(PROMPT_PATTERN) && !line.start_with?('=>')
703
+ next if line.match?(prompt_pattern) && !line.start_with?('=>')
650
704
 
651
705
  # Skip common IRB configuration output patterns
652
706
  if line.match?(/^(IRB\.conf|DISABLE_PRY_RAILS|Switch to inspect mode|Loading .*\.rb|nil)$/) ||
@@ -62,8 +62,10 @@ module Consolle
62
62
 
63
63
  # Wait for response (with timeout)
64
64
  begin
65
- logger.debug "[RequestBroker] Waiting for response: #{request_id}, timeout: #{request['timeout'] || 30}" if ENV['DEBUG']
66
- response = future.get(timeout: request['timeout'] || 30)
65
+ env_timeout = ENV['CONSOLLE_TIMEOUT']&.to_i
66
+ future_timeout = (env_timeout && env_timeout > 0) ? env_timeout : (request['timeout'] || 30)
67
+ logger.debug "[RequestBroker] Waiting for response: #{request_id}, timeout: #{future_timeout}" if ENV['DEBUG']
68
+ response = future.get(timeout: future_timeout)
67
69
  logger.debug "[RequestBroker] Got response: #{request_id}" if ENV['DEBUG']
68
70
  response
69
71
  rescue Timeout::Error
@@ -157,7 +159,8 @@ module Consolle
157
159
 
158
160
  def process_eval_request(request)
159
161
  code = request['code']
160
- timeout = request['timeout'] || 30
162
+ env_timeout = ENV['CONSOLLE_TIMEOUT']&.to_i
163
+ timeout = (env_timeout && env_timeout > 0) ? env_timeout : (request['timeout'] || 30)
161
164
 
162
165
  unless code
163
166
  return {
@@ -168,7 +171,11 @@ module Consolle
168
171
  end
169
172
 
170
173
  # Execute through supervisor
171
- result = @supervisor.eval(code, timeout: timeout)
174
+ if request.key?('pre_sigint')
175
+ result = @supervisor.eval(code, timeout: timeout, pre_sigint: request['pre_sigint'])
176
+ else
177
+ result = @supervisor.eval(code, timeout: timeout)
178
+ end
172
179
 
173
180
  # Format response
174
181
  if result[:success]
data/lib/consolle.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative 'consolle/version'
4
4
  require_relative 'consolle/constants'
5
5
  require_relative 'consolle/errors'
6
+ require_relative 'consolle/config'
6
7
  require_relative 'consolle/cli'
7
8
 
8
9
  # Server components
data/rule.ko.md CHANGED
@@ -20,11 +20,11 @@ Cone은 디버깅, 데이터 탐색, 그리고 개발 보조 도구로 사용됩
20
20
 
21
21
  ## Cone 서버 시작과 중지
22
22
 
23
- `start` 명령어로 cone을 시작할 수 있으며, `-e`로 실행 환경을 지정할 있습니다.
23
+ `start` 명령어로 cone을 시작할 수 있습니다. 실행 환경 지정은 `RAILS_ENV` 환경변수를 사용합니다.
24
24
 
25
25
  ```bash
26
- $ cone start # 서버 시작
27
- $ cone start -e test # test 환경에서 console 시작
26
+ $ cone start # 서버 시작 (RAILS_ENV가 없으면 development)
27
+ $ RAILS_ENV=test cone start # test 환경에서 console 시작
28
28
  ```
29
29
 
30
30
  중지와 재시작 명령어도 제공합니다.
@@ -114,4 +114,35 @@ users.first
114
114
  $ cone exec -f complex_task.rb
115
115
  ```
116
116
 
117
- 모든 방법은 세션 상태를 유지하므로 변수와 객체가 실행 간에 지속됩니다.
117
+ 모든 방법은 세션 상태를 유지하므로 변수와 객체가 실행 간에 지속됩니다.
118
+
119
+ ## 실행 안전장치 & 타임아웃
120
+
121
+ - 기본 타임아웃: 60초
122
+ - 타임아웃 우선순위: `CONSOLLE_TIMEOUT`(설정되고 0보다 클 때) > CLI `--timeout` > 기본값(60초)
123
+ - 사전 Ctrl‑C(프롬프트 분리):
124
+ - 매 `exec` 전에 Ctrl‑C를 보내고 IRB 프롬프트를 최대 3초 대기해 깨끗한 상태를 보장합니다.
125
+ - 3초 내 프롬프트가 돌아오지 않으면 콘솔 하위 프로세스를 재시작하고 요청은 `SERVER_UNHEALTHY`로 실패합니다.
126
+ - 서버 전역 비활성화: `CONSOLLE_DISABLE_PRE_SIGINT=1 cone start`
127
+ - 호출 단위 제어: `--pre-sigint` / `--no-pre-sigint`
128
+
129
+ ### 예시
130
+
131
+ ```bash
132
+ # CLI로 타임아웃 지정(환경변수 미설정 시 유효)
133
+ cone exec 'heavy_task' --timeout 120
134
+
135
+ # 최우선 타임아웃(클라이언트·서버 모두 적용)
136
+ CONSOLLE_TIMEOUT=90 cone exec 'heavy_task'
137
+
138
+ # 타임아웃 이후 복구 확인
139
+ cone exec 'sleep 999' --timeout 2 # -> EXECUTION_TIMEOUT로 실패
140
+ cone exec "puts :after_timeout; :ok" # -> 정상 동작(프롬프트 복구)
141
+
142
+ # 호출 단위로 사전 Ctrl‑C 비활성화
143
+ cone exec --no-pre-sigint 'code'
144
+ ```
145
+
146
+ ### 에러 코드
147
+ - `EXECUTION_TIMEOUT`: 실행한 코드가 타임아웃을 초과함
148
+ - `SERVER_UNHEALTHY`: 사전 프롬프트 확인(3초) 실패로 콘솔 재시작, 요청 실패
data/rule.md CHANGED
@@ -20,11 +20,11 @@ Existing objects also reference old code, so you need to create new ones to use
20
20
 
21
21
  ## Starting and Stopping Cone Server
22
22
 
23
- You can start cone with the `start` command and specify the execution environment with `-e`.
23
+ You can start cone with the `start` command. To select the Rails environment, set the `RAILS_ENV` environment variable.
24
24
 
25
25
  ```bash
26
- $ cone start # Start server
27
- $ cone start -e test # Start console in test environment
26
+ $ cone start # Start server (uses RAILS_ENV or defaults to development)
27
+ $ RAILS_ENV=test cone start # Start console in test environment
28
28
  ```
29
29
 
30
30
  It also provides stop and restart commands.
@@ -114,4 +114,35 @@ For complex multi-line code, save it in a file:
114
114
  $ cone exec -f complex_task.rb
115
115
  ```
116
116
 
117
- All methods maintain the session state, so variables and objects persist between executions.
117
+ All methods maintain the session state, so variables and objects persist between executions.
118
+
119
+ ## Execution Safety & Timeouts
120
+
121
+ - Default timeout: 60s
122
+ - Timeout precedence: `CONSOLLE_TIMEOUT` (if set and > 0) > CLI `--timeout` > default (60s)
123
+ - Pre-exec Ctrl-C (prompt separation):
124
+ - Before each `exec`, cone sends Ctrl-C and waits up to 3 seconds for the IRB prompt to ensure a clean state.
125
+ - If the prompt does not return in 3 seconds, the console subprocess is restarted and the request fails with `SERVER_UNHEALTHY`.
126
+ - Disable globally for the server: `CONSOLLE_DISABLE_PRE_SIGINT=1 cone start`
127
+ - Per-call control: `--pre-sigint` / `--no-pre-sigint`
128
+
129
+ ### Examples
130
+
131
+ ```bash
132
+ # Set timeout via CLI (fallback when CONSOLLE_TIMEOUT is not set)
133
+ cone exec 'heavy_task' --timeout 120
134
+
135
+ # Highest priority timeout (applies on client and server)
136
+ CONSOLLE_TIMEOUT=90 cone exec 'heavy_task'
137
+
138
+ # Verify recovery after a timeout
139
+ cone exec 'sleep 999' --timeout 2 # -> fails with EXECUTION_TIMEOUT
140
+ cone exec "puts :after_timeout; :ok" # -> should succeed (prompt recovered)
141
+
142
+ # Disable pre-exec Ctrl-C for a single call
143
+ cone exec --no-pre-sigint 'code'
144
+ ```
145
+
146
+ ### Error Codes
147
+ - `EXECUTION_TIMEOUT`: The executed code exceeded its timeout.
148
+ - `SERVER_UNHEALTHY`: The pre-exec prompt did not return within 3 seconds; the console subprocess was restarted and the request failed.
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: consolle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.3.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - nacyot
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-08-07 00:00:00.000000000 Z
10
+ date: 2025-12-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: logger
@@ -79,6 +79,7 @@ files:
79
79
  - lib/consolle.rb
80
80
  - lib/consolle/adapters/rails_console.rb
81
81
  - lib/consolle/cli.rb
82
+ - lib/consolle/config.rb
82
83
  - lib/consolle/constants.rb
83
84
  - lib/consolle/errors.rb
84
85
  - lib/consolle/server/console_socket_server.rb