Class: Pikuri::Lsp::ClientWrapper
- Inherits:
-
Object
- Object
- Pikuri::Lsp::ClientWrapper
- Defined in:
- lib/pikuri/lsp/client_wrapper.rb
Overview
One long-lived language server: the child process, the LSP handshake, and a restart when it dies under us. Connection carries the bytes; this owns the lifecycle around them.
client = ClientWrapper.spawn(entry, root: filesystem.project_root)
client.supports?('definitionProvider') # => true
client.position_encoding # => "utf-8" (ruby-lsp; jdtls stays utf-16)
client.wait_until_ready { |progress| emitter.call(progress) }
client.request('textDocument/definition', params, cancellable: cancellable)
client.close
#wait_until_ready before the first request is not optional politeness:
ruby-lsp answers a mid-index query with a successful [], which an agent
reads as "no definition exists". See Readiness.
ClientWrapper.spawn raises rather than returning a half-live object, so a missing binary or a server that dies during its handshake is a boot-time failure with the child's own last words attached (#stderr_tail) — the one thing that says why it died.
Capabilities are per workspace, not per binary
#supports? answers from this initialize response, never from a matrix
keyed on the binary: the same ruby-lsp advertises document formatting in a
project whose bundle has rubocop and withholds it in one that doesn't. That
is why the check belongs at dispatch time, and why #capabilities stays a
plain Hash — pikuri only ever asks whether a key is there.
Restart, and what a fresh server has forgotten
A Pikuri::Lsp::Connection::Closed mid-call means the child is gone for good, so
#request respawns and retries up to MAX_ATTEMPTS times before raising
ServerDied; a wrapper built with no respawn: (a test over pipes) raises
on the first death instead. Documents need no replay — pikuri re-sends
didOpen before every call anyway — but a fresh server's index is empty,
so a restart also puts #ready? back to false and the next
#wait_until_ready waits the new child out from scratch.
Documents: an agent is not an editor, so it re-opens
#open_document sends textDocument/didOpen with the file's current disk
text before every position-based call, and #close_document hands it back
when the call is done. No open-URI set and no version comparison — the pair
is per call, which is also what keeps it correct across a restart, since a
respawned child inherits no open documents. See #open_document for why
each alternative to re-opening was measured and rejected.
The Subprocess-seam exception
Subprocess.spawn is one-shot (stdin closed, output merged, read to EOF)
and a language server is the opposite on all three counts, so this is the
documented exception to the chokepoint convention alongside stdio MCP's:
Open3.popen3 here, and #close owns the teardown — shutdown, exit,
then SIGTERM to the whole process group, because jdtls forks helpers.
Defined Under Namespace
Classes: ServerDied
Constant Summary collapse
- MAX_ATTEMPTS =
Total #request attempts, including the first — 3 means one normal try plus two restart-then-retry rounds.
3- STDERR_TAIL_LINES =
Lines of the child's stderr kept for #stderr_tail. jdtls is chatty; what matters is the last thing it said before dying.
20- CLOSE_GRACE =
Seconds #close gives the child to leave on its own, at each escalation (EOF, then SIGTERM, then SIGKILL). Teardown is the one place with a clock, and this is what it is spent on: a close that blocks forever is worse than an impolite one.
2- REAP_ON_DEATH =
Seconds a death report waits for the child's exit status and its final stderr, both of which are in flight the moment stdout hits EOF.
0.5- CLIENT_CAPABILITIES =
What pikuri tells the server it can do. Four declarations are load-bearing, each measured against a real server:
positionEncodings(offer utf-8 and convert when a server ignores the offer),workDoneProgress(jdtls emits no$/progresswithout it, narrating startup through a non-spec notification instead),linkSupport(so a definition answersLocationLinkand pins the identifier's own range), andcontentFormatfor hover. The empty objects merely say "this operation exists here";documentSymboldeliberately does not claim hierarchical support, so symbols arrive flat. { general: { positionEncodings: PositionEncoding::OFFERED }, window: { workDoneProgress: true }, textDocument: { synchronization: { dynamicRegistration: false }, definition: { linkSupport: true }, typeDefinition: { linkSupport: true }, implementation: { linkSupport: true }, references: {}, hover: { contentFormat: %w[markdown plaintext] }, documentSymbol: {}, typeHierarchy: {}, callHierarchy: {} }, workspace: { symbol: {}, workspaceFolders: true } }.freeze
Instance Attribute Summary collapse
-
#capabilities ⇒ Hash{String => Object}
readonly
The server's advertised capabilities, verbatim from
initialize—{}before #start. -
#entry ⇒ Registry::StdioEntry
readonly
The configuration this client runs.
- #position_encoding ⇒ String? readonly
-
#server_info ⇒ Hash{String => Object}?
readonly
The server's
serverInfo(+name+ /version), when it sent one.
Class Method Summary collapse
-
.spawn(entry, root:, cancellable: nil) ⇒ ClientWrapper
Spawn the child, run the handshake, return a ready client.
Instance Method Summary collapse
-
#alive? ⇒ Boolean
Whether a request could still be answered.
-
#close ⇒ void
Shut the server down:
shutdown,exit, close the channel, then signal anything still breathing. -
#close_document(path) ⇒ void
Hand the document back, so the server returns to reading it from disk.
-
#initialize(entry:, root:, stdin:, stdout:, stderr: nil, wait_thread: nil, respawn: nil, readiness: nil) ⇒ ClientWrapper
constructor
Adopts the channel and starts reading it, but sends nothing: #start is the handshake.
-
#notify(method, params = {}) ⇒ void
Send a notification (+textDocument/didOpen+,
exit), restarting on death like #request — a notification that vanishes into a dead pipe would otherwise be discovered by the next request answering nonsense. -
#on_notification {|method, params| ... } ⇒ void
Register a server-notification handler, kept across restarts and re-attached to each new channel.
-
#open_document(path, text) ⇒ void
Tell the server what this file says now, by re-opening it.
-
#pid ⇒ Integer?
The child's pid,
nilover pipes. -
#ready? ⇒ Boolean
Whether the server has finished indexing — see Readiness for what that means and why it is one rule for every server.
-
#request(method, params = {}, cancellable: nil) ⇒ Hash, ...
Send a request, restarting the server and retrying if it dies.
-
#start(cancellable: nil) ⇒ self
Run the LSP handshake:
initialize, theninitialized. -
#stderr_tail ⇒ String
The child's last STDERR_TAIL_LINES stderr lines, newline-joined,
""when it said nothing (or when there is no child). -
#supports?(name) ⇒ Boolean
Whether the server advertised an operation in this workspace.
-
#wait_until_ready(cancellable: nil) {|progress| ... } ⇒ true
Block until the index is built, yielding progress while it is not.
Constructor Details
#initialize(entry:, root:, stdin:, stdout:, stderr: nil, wait_thread: nil, respawn: nil, readiness: nil) ⇒ ClientWrapper
Adopts the channel and starts reading it, but sends nothing: #start is
the handshake. Splitting the two is what lets a fake server drive a
wrapper over IO.pipe, with no binary in sight.
194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 194 def initialize(entry:, root:, stdin:, stdout:, stderr: nil, wait_thread: nil, respawn: nil, readiness: nil) @entry = entry @root = File.(root.to_s) @respawn = respawn @handlers = [] @capabilities = {} @stderr_tail = [] @stderr_io = nil @tail_mutex = Mutex.new @started = false @document_version = 0 @closed = false @readiness = readiness || Readiness.new(server_id: entry.id) adopt(stdin: stdin, stdout: stdout, stderr: stderr, wait_thread: wait_thread) on_notification { |method, params| @readiness.observe(method, params) } end |
Instance Attribute Details
#capabilities ⇒ Hash{String => Object} (readonly)
Returns the server's advertised capabilities,
verbatim from initialize — {} before #start. Values are unions in
practice (+true+ from one server, an options object from another), which
is why nothing types them.
164 165 166 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 164 def capabilities @capabilities end |
#entry ⇒ Registry::StdioEntry (readonly)
Returns the configuration this client runs.
158 159 160 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 158 def entry @entry end |
#position_encoding ⇒ String? (readonly)
Returns the negotiated positionEncoding — every
Position crossing this channel converts with it — or nil before
#start. PositionEncoding::DEFAULT when the server answers nothing,
which is jdtls's answer.
174 175 176 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 174 def position_encoding @position_encoding end |
#server_info ⇒ Hash{String => Object}? (readonly)
Returns the server's serverInfo
(+name+ / version), when it sent one.
168 169 170 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 168 def server_info @server_info end |
Class Method Details
.spawn(entry, root:, cancellable: nil) ⇒ ClientWrapper
Spawn the child, run the handshake, return a ready client.
132 133 134 135 136 137 138 139 140 141 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 132 def self.spawn(entry, root:, cancellable: nil) respawn = -> { spawn_child(entry, root) } wrapper = new(entry: entry, root: root, respawn: respawn, **respawn.call) begin wrapper.start(cancellable: cancellable) rescue StandardError wrapper.close raise end end |
Instance Method Details
#alive? ⇒ Boolean
Returns whether a request could still be answered.
393 394 395 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 393 def alive? !@closed && @connection.alive? end |
#close ⇒ void
This method returns an undefined value.
Shut the server down: shutdown, exit, close the channel, then signal
anything still breathing. Idempotent, and never raises — teardown runs at
exit, where a raise would take the rest of the sweep with it.
414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 414 def close return if @closed @closed = true say_goodbye @connection.close # After the child is gone, not before: closing our read end while it is # still writing hands a dying server EPIPE on its own log channel. reap_child close_stderr nil rescue StandardError => e LOGGER.warn("#{@entry.id}: teardown raised #{e.class}: #{e.}") nil end |
#close_document(path) ⇒ void
This method returns an undefined value.
Hand the document back, so the server returns to reading it from disk.
Balances #open_document (+D_lsp_document_sync+), and must go out after the reply to the request it brackets: a server that answers concurrently can otherwise process the close first and answer about a document it has just dropped.
Alone among the notifications here it neither restarts nor raises — a
respawned child never received the didOpen this balances, so there is
nothing for a retry to reach.
323 324 325 326 327 328 329 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 323 def close_document(path) return unless alive? @connection.notify('textDocument/didClose', { textDocument: { uri: Uris.for_path(path) } }) rescue Connection::Closed => e LOGGER.debug("#{@entry.id}: could not close #{path}: #{e.}") end |
#notify(method, params = {}) ⇒ void
This method returns an undefined value.
Send a notification (+textDocument/didOpen+, exit), restarting on death
like #request — a notification that vanishes into a dead pipe would
otherwise be discovered by the next request answering nonsense.
250 251 252 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 250 def notify(method, params = {}) with_restart(nil) { @connection.notify(method, params) } end |
#on_notification {|method, params| ... } ⇒ void
This method returns an undefined value.
Register a server-notification handler, kept across restarts and re-attached to each new channel.
338 339 340 341 342 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 338 def on_notification(&block) @handlers << block @connection.on_notification(&block) nil end |
#open_document(path, text) ⇒ void
This method returns an undefined value.
Tell the server what this file says now, by re-opening it.
client.open_document(path, filesystem.resolve_for_read(path).read)
This is the whole document-sync policy, and it is sound rather than lucky
because pikuri has no unsaved buffers. An editor owns text the disk
does not have, which is the only reason LSP's sync model exists; write
and edit land on disk immediately, so the disk is always the truth and
the server's copy is only ever a cache of it. Re-opening is how you say
"here is the truth again", and the text it needs is the text the tool has
already read to locate the symbol.
Every alternative was measured and each fails differently:
- A rangeless whole-document +didChange+ is not ours to send: both
servers advertise incremental sync, and
TextDocumentSyncKindis the server's declaration of which shape it accepts. jdtls tolerates it; ruby-lsp raises insidepush_edits, which readsedit[:range][:start]unconditionally, and a notification has no reply channel, so the only report is awindow/logMessageline while the document keeps its old text. - The ranged whole-document change is honoured by both, and then
desynchronizes ruby-lsp's index from the document it just updated:
RubyDocument#should_index?reindexes only when the edit's start position looks like a declaration, and a whole-file replace starts at 0:0 — thefrozen_string_literalcomment.documentSymbolthen reports the new name whiledefinitionanswers[]for it. - +didClose+ alone, as the way to refresh: it resyncs ruby-lsp (its store entry is dropped, so the next request re-reads disk) and does nothing for jdtls, which keeps answering its pre-close copy. Rejected as the mechanism — #close_document still sends it, to balance the open rather than to refresh anything.
- The spec's own out-of-band channel,
didChangeWatchedFiles, is ignored by both for a URI the client has opened — a client that opened a document is presumed to be its authority, which is exactly the assumption an agent writing straight to disk breaks.
Re-opening was the only mechanism that worked on both servers, refreshing document and index. The cost, named: the server re-parses the file on every call. That is a local child process doing what it does on every keystroke in an editor, and the alternative is a correctness bug that reports success.
303 304 305 306 307 308 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 303 def open_document(path, text) @document_version += 1 notify('textDocument/didOpen', { textDocument: { uri: Uris.for_path(path), languageId: @entry.language_id, version: @document_version, text: text } }) end |
#pid ⇒ Integer?
Returns the child's pid, nil over pipes.
405 406 407 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 405 def pid @wait_thread&.pid end |
#ready? ⇒ Boolean
Returns whether the server has finished indexing — see
Readiness for what that means and why it is one rule for every
server. A request sent while this is false is answered wrongly by
ruby-lsp and not at all by jdtls.
358 359 360 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 358 def ready? @readiness.ready? end |
#request(method, params = {}, cancellable: nil) ⇒ Hash, ...
Send a request, restarting the server and retrying if it dies.
238 239 240 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 238 def request(method, params = {}, cancellable: nil) with_restart(cancellable) { @connection.request(method, params, cancellable: cancellable) } end |
#start(cancellable: nil) ⇒ self
Run the LSP handshake: initialize, then initialized.
219 220 221 222 223 224 225 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 219 def start(cancellable: nil) raise "#{@entry.id}: already started" if @started handshake(cancellable) @started = true self end |
#stderr_tail ⇒ String
Returns the child's last STDERR_TAIL_LINES stderr lines,
newline-joined, "" when it said nothing (or when there is no child).
Reset on restart, so this is always the current child's account.
400 401 402 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 400 def stderr_tail @tail_mutex.synchronize { @stderr_tail.join("\n") } end |
#supports?(name) ⇒ Boolean
Whether the server advertised an operation in this workspace.
349 350 351 352 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 349 def supports?(name) value = @capabilities[name] !value.nil? && value != false end |
#wait_until_ready(cancellable: nil) {|progress| ... } ⇒ true
Block until the index is built, yielding progress while it is not.
client.wait_until_ready(cancellable: cancellable) { |progress| emitter.call(progress) }
There is no timeout: a cold jdtls import is minutes, no number separates
a slow index from a hung one, and the yielded ServerProgress is what
keeps the block legible rather than a hang. What ends a wait instead is
readiness, the child's death, or cancellable — the human as the
timeout. A server that dies waiting is restarted and waited on again,
MAX_ATTEMPTS times, because a fresh child indexes from nothing — but a
replacement that cannot get through its handshake ends the attempt
there and then, so a misconfigured server costs one respawn rather than
a spawn per attempt.
383 384 385 386 387 388 389 390 |
# File 'lib/pikuri/lsp/client_wrapper.rb', line 383 def wait_until_ready(cancellable: nil, &progress) progress ||= ->(_update) {} with_restart(cancellable) do next true if @readiness.wait(cancellable: cancellable, alive: -> { alive? }, &progress) raise Connection::Closed, 'died while its index was building' end end |