Class: Pikuri::Lsp::Connection
- Inherits:
-
Object
- Object
- Pikuri::Lsp::Connection
- 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
-
#alive? ⇒ Boolean
Whether the channel can still carry a request.
- #close ⇒ void
-
#dead_reason ⇒ String?
Why the channel died —
"server closed stdout (EOF)", a truncated frame, a parse failure,"closed by pikuri"— ornilwhile it is alive. -
#initialize(stdin:, stdout:, server_id: 'lsp') ⇒ Connection
constructor
Starts the reader thread immediately, so a server that talks first is heard.
-
#notify(method, params = {}) ⇒ void
Send a notification — no id, no reply, returns as soon as the bytes are written.
-
#on_notification {|method, params| ... } ⇒ void
Register a handler for every server notification (+$/progress+,
window/logMessage,language/status, …), called in registration order on the reader thread. -
#request(method, params = {}, cancellable: nil) ⇒ Hash, ...
Send a request and block until the server answers it.
-
#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
shutdownmust reach the server ahead ofexit.
Constructor Details
#initialize(stdin:, stdout:, server_id: 'lsp') ⇒ Connection
Starts the reader thread immediately, so a server that talks first is heard.
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.
180 181 182 |
# File 'lib/pikuri/lsp/connection.rb', line 180 def alive? @state.synchronize { @dead.nil? } end |
#close ⇒ void
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_reason ⇒ String?
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.
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.
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.
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.
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.
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 |