Class: Mbeditor::RubyLspClient

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

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root) ⇒ RubyLspClient

Returns a new instance of RubyLspClient.



69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/mbeditor/ruby_lsp_client.rb', line 69

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

#stateObject (readonly)

Returns the value of attribute state.



83
84
85
# File 'lib/mbeditor/ruby_lsp_client.rb', line 83

def state
  @state
end

Class Method Details

.for(workspace_root) ⇒ Object



39
40
41
42
43
44
45
46
47
48
49
# File 'lib/mbeditor/ruby_lsp_client.rb', line 39

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.



63
64
65
66
# File 'lib/mbeditor/ruby_lsp_client.rb', line 63

def reset!
  stop_all
  REGISTRY_MUTEX.synchronize { @registry = {} }
end

.stop_allObject



51
52
53
54
55
56
57
58
59
60
# File 'lib/mbeditor/ruby_lsp_client.rb', line 51

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

#await_response(method, id, queue, timeout) ⇒ Object



164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/mbeditor/ruby_lsp_client.rb', line 164

def await_response(method, id, queue, timeout)
  msg = pop_with_timeout(queue, timeout)
  if msg.nil?
    # Nobody is going to read this answer; tell the server to stop
    # computing it rather than leave it indexing for an abandoned caller.
    cancel_request(id)
    raise TimeoutError, "#{method} timed out after #{timeout}s"
  end
  raise Error, msg["error"]["message"].to_s if msg["error"]

  msg["result"]
ensure
  @pending_mutex.synchronize { @pending.delete(id) }
end

#healthObject

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.

Deliberately lock-free: @state_mutex is held for the whole of a start (up to INIT_TIMEOUT) and a restart, and a status chip polled every 10s must not queue behind a handshake. Each field is one reference read, so the worst case is a snapshot that is one transition out of date.



97
98
99
# File 'lib/mbeditor/ruby_lsp_client.rb', line 97

def health
  { state: @state, restarts: @crash_times.length, error: @last_error }
end

#pop_with_timeout(queue, timeout) ⇒ Object



183
184
185
186
187
188
189
190
191
# File 'lib/mbeditor/ruby_lsp_client.rb', line 183

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

Returns:

  • (Boolean)


85
86
87
88
# File 'lib/mbeditor/ruby_lsp_client.rb', line 85

def ready?
  ensure_started
  @state == :ready
end

#request(method, params, timeout: nil) ⇒ Object



138
139
140
141
142
# File 'lib/mbeditor/ruby_lsp_client.rb', line 138

def request(method, params, timeout: nil)
  timeout ||= (Mbeditor.configuration.ruby_lsp_timeout || 3).to_f
  id, queue = send_request(method, params)
  await_response(method, id, queue, timeout)
end

#request_with_document(method, path, content, params = {}, timeout: nil) ⇒ Object

Syncs the document (didOpen / full-text didChange) and issues a request against it, 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.

messages in order, so once the request is on the wire behind its didOpen the server already sees the right document. Holding it across the round-trip serialised every ruby-lsp request in the process for up to the full request timeout.

Raises:



126
127
128
129
130
131
132
133
134
135
136
# File 'lib/mbeditor/ruby_lsp_client.rb', line 126

def request_with_document(method, path, content, params = {}, timeout: nil)
  raise NotReadyError, "ruby-lsp is not running" unless ready?

  uri = file_uri(path)
  timeout ||= (Mbeditor.configuration.ruby_lsp_timeout || 3).to_f
  id, queue = @doc_mutex.synchronize do
    sync_document(uri, content)
    send_request(method, params.merge(textDocument: { uri: uri }))
  end
  await_response(method, id, queue, timeout)
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.



105
106
107
108
109
110
111
112
113
# File 'lib/mbeditor/ruby_lsp_client.rb', line 105

def reset!
  stop
  @state_mutex.synchronize do
    @crash_times.clear
    @last_error = nil
    @state = :stopped
  end
  ready?
end

#send_request(method, params) ⇒ Object

Registers a response queue and puts the request on the wire. Returns [id, queue] for #await_response.



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/mbeditor/ruby_lsp_client.rb', line 146

def send_request(method, params)
  queue = Queue.new
  id = @pending_mutex.synchronize do
    @next_id += 1
    @pending[@next_id] = queue
    @next_id
  end

  begin
    write_message({ jsonrpc: "2.0", id: id, method: method, params: params })
  rescue StandardError
    @pending_mutex.synchronize { @pending.delete(id) }
    raise
  end

  [id, queue]
end

#stopObject



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
# File 'lib/mbeditor/ruby_lsp_client.rb', line 193

def stop
  @state_mutex.synchronize do
    next unless @wait_thr

    @stopping = true
    begin
      request("shutdown", nil, timeout: 1)
    rescue StandardError
      nil
    end
    begin
      write_message({ 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