Class: Pikuri::Lsp::Connection

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/lsp/connection.rb

Overview

LSP's JSON-RPC channel over one pair of IOs: framing, a reader thread that demuxes replies by id, and the answer every server-initiated request needs. Given a subprocess's stdin/stdout it turns an asynchronous bidirectional stream into a blocking #request plus a fire-and-forget #notify:

conn = Connection.new(stdin: child_in, stdout: child_out, server_id: 'ruby')
conn.on_notification do |method, params|
puts params['message'] if method == 'window/logMessage'
end
caps = conn.request('initialize', { processId: Process.pid, rootUri: root_uri })
conn.notify('initialized', {})
conn.request('textDocument/definition', params, cancellable: cancellable)
conn.close

It knows no LSP vocabulary — no handshake, no capabilities, no document state — and it spawns nothing: the IO pair arrives from outside, which is what lets a fake server drive it over IO.pipe in-process.

There is no timeout

#request blocks until the server answers, however long that takes: a cold jdtls import is minutes, and no defensible number separates "slow index" from "hung". Two things replace the clock here — the child's death (EOF on stdout ends the wait with Closed) and the cancellable: poll, which makes the human the timeout via Ctrl+C. The third is the server's own progress notifications, which a handler turns into ServerProgress so that a long wait at least looks like one.

Implementation details

Server→client requests (+window/workDoneProgress/create+, workspace/configuration, client/registerCapability) are answered result: null from the reader thread, because a server may block on the reply and would then never answer the request pikuri is waiting for. No hook to supply a real result: both measured servers proceed fine, and an invented answer is worse than none.

A JSON-RPC error member is a ServerError, distinct from any other failure on purpose — "the server refused this method" (ruby-lsp answers Method not found for an operation it never advertised) must not read like "the server answered nothing".

Thread-safety

Thread-safe, which is the point: one reader thread owns stdout while any number of callers write and wait. Notification handlers are the exception to watch — they run on the reader thread, so a handler must not touch anything thread-confined. Park the value there and let the waiting thread pick it up.

Defined Under Namespace

Classes: Closed, ServerError

Constant Summary collapse

POLL_INTERVAL =

How often a blocked #request wakes to re-check cancellable.

0.1
CLOSE_JOIN_TIMEOUT =

How long #close waits for the reader thread to notice its IOs went away before giving up on it.

2

Instance Method Summary collapse

Constructor Details

#initialize(stdin:, stdout:, server_id: 'lsp') ⇒ Connection

Starts the reader thread immediately, so a server that talks first is heard.

Parameters:

  • stdin (IO)

    the server's standard input — pikuri writes here.

  • stdout (IO)

    the server's standard output — the reader thread owns it exclusively from here on.

  • server_id (String) (defaults to: 'lsp')

    the registry entry's id, for log lines only.



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/pikuri/lsp/connection.rb', line 101

def initialize(stdin:, stdout:, server_id: 'lsp')
  @stdin = stdin
  @stdout = stdout
  @server_id = server_id
  @write_mutex = Mutex.new
  @state = Mutex.new
  @answered = ConditionVariable.new
  @pending = {}
  @handlers = []
  @next_id = 0
  @dead = nil
  @closed = false
  @reader = Thread.new { read_loop }
  @reader.name = "lsp-reader #{server_id}"
end

Instance Method Details

#alive?Boolean

Returns whether the channel can still carry a request.

Returns:

  • (Boolean)

    whether the channel can still carry a request.



180
181
182
# File 'lib/pikuri/lsp/connection.rb', line 180

def alive?
  @state.synchronize { @dead.nil? }
end

#closevoid

This method returns an undefined value.

Close both IOs and stop the reader thread; every waiting and subsequent #request raises Closed. Idempotent, and sends no shutdown — protocol lifecycle belongs to the layer that spawned the child.



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/pikuri/lsp/connection.rb', line 197

def close
  already_closed = @state.synchronize do
    was_closed = @closed
    @closed = true
    was_closed
  end
  return if already_closed

  die!('closed by pikuri')
  close_io(@stdin)
  close_io(@stdout)
  return if @reader.join(CLOSE_JOIN_TIMEOUT)

  LOGGER.warn("#{@server_id}: reader thread did not stop within #{CLOSE_JOIN_TIMEOUT}s; killing it")
  @reader.kill
end

#dead_reasonString?

Returns why the channel died — +"server closed stdout (EOF)"+, a truncated frame, a parse failure, "closed by pikuri" — or nil while it is alive. What the child said on its way out is the stderr tail, which belongs to whoever spawned it.

Returns:

  • (String, nil)

    why the channel died — +"server closed stdout (EOF)"+, a truncated frame, a parse failure, "closed by pikuri" — or nil while it is alive. What the child said on its way out is the stderr tail, which belongs to whoever spawned it.



188
189
190
# File 'lib/pikuri/lsp/connection.rb', line 188

def dead_reason
  @state.synchronize { @dead }
end

#notify(method, params = {}) ⇒ void

This method returns an undefined value.

Send a notification — no id, no reply, returns as soon as the bytes are written.

Parameters:

  • method (String)

    e.g. "textDocument/didOpen".

  • params (Hash) (defaults to: {})

Raises:

  • (Closed)

    if the channel is already gone or the write fails.



163
164
165
# File 'lib/pikuri/lsp/connection.rb', line 163

def notify(method, params = {})
  write(jsonrpc: '2.0', method: method, params: params)
end

#on_notification {|method, params| ... } ⇒ void

This method returns an undefined value.

Register a handler for every server notification (+$/progress+, window/logMessage, language/status, …), called in registration order on the reader thread. One that raises is logged and skipped rather than taking the reader — and with it the channel — down.

Yield Parameters:

  • method (String)
  • params (Hash, nil)


175
176
177
# File 'lib/pikuri/lsp/connection.rb', line 175

def on_notification(&block)
  @state.synchronize { @handlers << block }
end

#request(method, params = {}, cancellable: nil) ⇒ Hash, ...

Send a request and block until the server answers it.

Parameters:

  • method (String)

    LSP method name, e.g. "textDocument/definition".

  • params (Hash) (defaults to: {})

    the params member, serialized as-is.

  • cancellable (Pikuri::Agent::Control::Cancellable, nil) (defaults to: nil)

    polled every POLL_INTERVAL while waiting; its Cancelled propagates.

Returns:

  • (Hash, Array, String, Integer, true, false, nil)

    the result member verbatim. nil is a real answer ("nothing found"), not an error — and java/classFileContents answers a bare String where the spec suggests an object, so callers accept what they get.

Raises:

  • (ServerError)

    on a JSON-RPC error response.

  • (Closed)

    if the channel dies before the answer arrives.

  • (Pikuri::Agent::Control::Cancellable::Cancelled)

    on cancellation.



130
131
132
133
134
135
136
137
138
139
# File 'lib/pikuri/lsp/connection.rb', line 130

def request(method, params = {}, cancellable: nil)
  id = register_request
  begin
    write(jsonrpc: '2.0', id: id, method: method, params: params)
  rescue StandardError
    @state.synchronize { @pending.delete(id) }
    raise
  end
  await(id, method, cancellable)
end

#send_request(method, params = {}) ⇒ Integer

Send a request and deliberately not wait for its answer, for the one case where the answer changes nothing: teardown, where shutdown must reach the server ahead of exit. A reply that does arrive is dropped as an unknown id.

Parameters:

  • method (String)

    e.g. "shutdown".

  • params (Hash) (defaults to: {})

Returns:

  • (Integer)

    the id it went out with.

Raises:

  • (Closed)

    if the channel is already gone or the write fails.



150
151
152
153
154
# File 'lib/pikuri/lsp/connection.rb', line 150

def send_request(method, params = {})
  id = next_id!
  write(jsonrpc: '2.0', id: id, method: method, params: params)
  id
end