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.



68
69
70
71
72
73
74
75
76
77
78
79
# 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  = []
end

Instance Attribute Details

#stateObject (readonly)

Returns the value of attribute state.



81
82
83
# File 'lib/mbeditor/ruby_lsp_client.rb', line 81

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_allObject



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

#pop_with_timeout(queue, timeout) ⇒ Object



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

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)


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

def ready?
  ensure_started
  @state == :ready
end

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



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/mbeditor/ruby_lsp_client.rb', line 103

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

  write_message({ 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.

Raises:



93
94
95
96
97
98
99
100
101
# File 'lib/mbeditor/ruby_lsp_client.rb', line 93

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

#stopObject



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/mbeditor/ruby_lsp_client.rb', line 137

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