Class: Mbeditor::RubyLspClient
- Inherits:
-
Object
- Object
- Mbeditor::RubyLspClient
- Defined in:
- lib/mbeditor/ruby_lsp_client.rb
Overview
Manages a persistent ruby-lsp process per workspace and speaks the Language Server Protocol to it over stdio (Content-Length framing).
Lives in lib/ (required from lib/mbeditor.rb, NOT autoloaded) so Zeitwerk code reloads in the host's dev environment cannot wipe the registry and orphan the child process.
Usage:
client = RubyLspClient.for(workspace_root)
client.request_with_document("textDocument/definition", abs_path, content,
position: { line: 0, character: 4 })
Raises NotReadyError / TimeoutError for callers to fall back to the legacy grep/Ripper services.
Defined Under Namespace
Classes: Error, NotReadyError, TimeoutError
Constant Summary collapse
- INIT_TIMEOUT =
seconds for the initialize handshake
15- SHUTDOWN_GRACE =
seconds before pgroup KILL
2- MAX_RESTARTS =
3- RESTART_WINDOW =
seconds
300- RESTART_BACKOFFS =
min seconds between crash and retry
[1, 5, 25].freeze
- QUEUE_POP_SUPPORTS_TIMEOUT =
Queue#pop only accepts a timeout: keyword from Ruby 3.2 onwards, and the gem supports 3.0. Returns nil on timeout either way.
RUBY_VERSION >= "3.2"
Instance Attribute Summary collapse
-
#state ⇒ Object
readonly
Returns the value of attribute state.
Class Method Summary collapse
Instance Method Summary collapse
-
#health ⇒ Object
A snapshot for the editor's status indicator.
-
#initialize(root) ⇒ RubyLspClient
constructor
A new instance of RubyLspClient.
- #pop_with_timeout(queue, timeout) ⇒ Object
- #ready? ⇒ Boolean
- #request(method, params, timeout: nil) ⇒ Object
-
#request_with_document(method, path, content, params = {}, timeout: nil) ⇒ Object
Syncs the document (didOpen / full-text didChange) and issues a request against it under one mutex, so concurrent Puma threads can't interleave a positional request with a stale document.
-
#reset! ⇒ Object
Clears the crash budget so a client latched at :failed can be revived without restarting the whole Rails process.
- #stop ⇒ Object
Constructor Details
#initialize(root) ⇒ RubyLspClient
Returns a new instance of RubyLspClient.
68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/mbeditor/ruby_lsp_client.rb', line 68 def initialize(root) @root = root @state_mutex = Mutex.new # lifecycle transitions @write_mutex = Mutex.new # stdin framing @pending_mutex = Mutex.new # id => Queue map + id allocation @doc_mutex = Mutex.new # document sync ordered with positional requests @pending = {} @docs = {} @next_id = 0 @state = :stopped # :stopped | :ready | :crashed | :failed @crash_times = [] @last_error = nil # last start failure, surfaced to the editor's status chip end |
Instance Attribute Details
#state ⇒ Object (readonly)
Returns the value of attribute state.
82 83 84 |
# File 'lib/mbeditor/ruby_lsp_client.rb', line 82 def state @state end |
Class Method Details
.for(workspace_root) ⇒ Object
38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/mbeditor/ruby_lsp_client.rb', line 38 def for(workspace_root) root = workspace_root.to_s REGISTRY_MUTEX.synchronize do @registry ||= {} unless @at_exit_registered at_exit { stop_all } @at_exit_registered = true end @registry[root] ||= new(root) end end |
.reset! ⇒ Object
Tests only.
62 63 64 65 |
# File 'lib/mbeditor/ruby_lsp_client.rb', line 62 def reset! stop_all REGISTRY_MUTEX.synchronize { @registry = {} } end |
.stop_all ⇒ Object
50 51 52 53 54 55 56 57 58 59 |
# File 'lib/mbeditor/ruby_lsp_client.rb', line 50 def stop_all clients = REGISTRY_MUTEX.synchronize { (@registry || {}).values.dup } clients.each do |c| begin c.stop rescue StandardError nil end end end |
Instance Method Details
#health ⇒ Object
A snapshot for the editor's status indicator. Deliberately does not start the process — asking "how are you?" must not be what boots the server.
91 92 93 94 95 |
# File 'lib/mbeditor/ruby_lsp_client.rb', line 91 def health @state_mutex.synchronize do { state: @state, restarts: @crash_times.length, error: @last_error } end end |
#pop_with_timeout(queue, timeout) ⇒ Object
150 151 152 153 154 155 156 157 158 |
# File 'lib/mbeditor/ruby_lsp_client.rb', line 150 def pop_with_timeout(queue, timeout) return queue.pop(timeout: timeout) if QUEUE_POP_SUPPORTS_TIMEOUT begin Timeout.timeout(timeout) { queue.pop } rescue Timeout::Error nil end end |
#ready? ⇒ Boolean
84 85 86 87 |
# File 'lib/mbeditor/ruby_lsp_client.rb', line 84 def ready? ensure_started @state == :ready end |
#request(method, params, timeout: nil) ⇒ Object
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/mbeditor/ruby_lsp_client.rb', line 126 def request(method, params, timeout: nil) timeout ||= (Mbeditor.configuration.ruby_lsp_timeout || 3).to_f queue = Queue.new id = @pending_mutex.synchronize do @next_id += 1 @pending[@next_id] = queue @next_id end ({ jsonrpc: "2.0", id: id, method: method, params: params }) msg = pop_with_timeout(queue, timeout) raise TimeoutError, "#{method} timed out after #{timeout}s" if msg.nil? raise Error, msg["error"]["message"].to_s if msg["error"] msg["result"] ensure @pending_mutex.synchronize { @pending.delete(id) } if id end |
#request_with_document(method, path, content, params = {}, timeout: nil) ⇒ Object
Syncs the document (didOpen / full-text didChange) and issues a request
against it under one mutex, so concurrent Puma threads can't interleave
a positional request with a stale document.
params is an explicit hash (not keywords) so it can't collide with the
timeout: keyword.
116 117 118 119 120 121 122 123 124 |
# File 'lib/mbeditor/ruby_lsp_client.rb', line 116 def request_with_document(method, path, content, params = {}, timeout: nil) raise NotReadyError, "ruby-lsp is not running" unless ready? uri = "file://#{path}" @doc_mutex.synchronize do sync_document(uri, content) request(method, params.merge(textDocument: { uri: uri }), timeout: timeout) end end |
#reset! ⇒ Object
Clears the crash budget so a client latched at :failed can be revived without restarting the whole Rails process. Clearing @crash_times is the load-bearing part: restart_allowed? re-latches :failed immediately if the window still holds MAX_RESTARTS entries.
101 102 103 104 105 106 107 108 109 |
# File 'lib/mbeditor/ruby_lsp_client.rb', line 101 def reset! stop @state_mutex.synchronize do @crash_times.clear @last_error = nil @state = :stopped end ready? end |
#stop ⇒ Object
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 |
# File 'lib/mbeditor/ruby_lsp_client.rb', line 160 def stop @state_mutex.synchronize do next unless @wait_thr @stopping = true begin request("shutdown", nil, timeout: 1) rescue StandardError nil end begin ({ jsonrpc: "2.0", method: "exit" }) rescue StandardError nil end unless @wait_thr.join(SHUTDOWN_GRACE) begin Process.kill("-KILL", @wait_thr.pid) rescue StandardError nil end end cleanup_process @state = :stopped @stopping = false end end |