Skip to content

prod-code lsp gives editors rust-analyzer, gopls and clangd themselves on the node; Zed extension - #337

Merged
alex09x merged 2 commits into
mainfrom
feat/editor-servers
Sep 25, 2026
Merged

alex09x merged 2 commits into
mainfrom
feat/editor-servers

Conversation

@alex09x

@alex09x alex09x commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Problem

prod-code lsp served an editor from prod-code's own engines, not from the server the editor would run locally (#332). For Rust that was the in-memory engine; for the other languages, a server the gateway had initialised for itself. The editor's settings never reached the server, and neither did the server's requests. Nothing ran cargo check on save, and rust-analyzer's protocol extensions were refused. Run with a scripted editor session on a one-file crate that moves v twice (rustc's E0382), prod-code 0.3.8 with #320 deployed:

initialize 0.74s serverInfo={'name': 'prod-code', 'version': '0.3.8'}
rust-analyzer/expandMacro -> ERROR {"code": -32601, "message": "rust-analyzer/expandMacro is not supported by prod-code for Rust"}
notify textDocument/didSave
diagnostics after 15 s: {"src/lib.rs": []}

Two more gaps stood in the way of an editor:

Change

  • Gateway, editor_proxy. A session whose handshake says purpose: "editor" gets a language server process of its own, started in the node's copy of the checkout.
    • The server is the language's own: rust-analyzer, gopls, clangd (for_cpp), basedpyright, the TypeScript server or sourcekit-lsp.
    • Every message is carried both ways with its paths translated, and nothing else is changed except processId in initialize, which is dropped. That id names a process on the editor's machine, and basedpyright exited 4 s after it found no such process on the node (measured below; rust-analyzer, gopls and clangd kept running).
    • The server ends with the session.
    • After a sync, the editors' servers are told which files changed (workspace/didChangeWatchedFiles, registry EditorServers).
    • Without the server on the node, or with PROD_CODE_EDITOR_SERVERS=off, the session is served by the shared engines as before.
  • Client, prod-code lsp.
    • --language <lang> asks for one language's server, and places a language other than the root's under a key of its own, e.g. Swift on a macOS node.
    • textDocument/didSave and workspace/didChangeWatchedFiles wait for a push of the checkout, so that check on save checks what was saved. The push and the background syncs take turns.
    • editor_files::RemoteFiles copies a file the server names that the editor cannot open into a read-only mirror under the user's cache directory, and names the copy. The editor's messages about a copy name the node's path again. Toolchain and registry files are fetched once; others are fetched each time.
    • Frames are read with every header, case-insensitively.
    • PROD_CODE_LSP_TRACE=<file> logs every message.
  • editors/zed. A Zed extension (zed_extension_api 0.7.0, builds to a 304 KB wasm32-wasip2 component). It has one server per language (prod-code-rust, -go, -cpp, -python, -typescript, -swift), each running prod-code lsp --language <lang>. The user's settings for the local server a prod-code server stands in for (lsp.rust-analyzer.* and so on) are passed on. Its README also shows how to point Zed's own rust-analyzer at prod-code lsp, which keeps every Rust feature Zed wires to rust-analyzer by name.
  • The rust-analyzer component was installed on the two Linux nodes that lacked it (rustup component add rust-analyzer).

Reproduce and verify

The live tests run a real gateway and real servers on a Linux build node:

an_editor_gets_rust_analyzer_itself_on_the_node          ok
  serverInfo "rust-analyzer"; rust-analyzer/expandMacro expands format!;
  didSave -> cargo check on the node -> publishDiagnostics with code E0382
an_editors_python_server_does_not_watch_the_editors_process   ok
  initialize with a processId that names nothing on the node; 8 s later hover still answers
an_editor_session_gets_what_an_editor_needs_for_rust          ok   (PROD_CODE_EDITOR_SERVERS=off: the in-memory engine, as in #320)

In the CLI test lsp_asks_for_its_language_pushes_a_save_first_and_mirrors_node_files:

  • the handshake carries engine=go;
  • a sync lands between initialize and the didSave it precedes;
  • a definition at /srv/toolchains/std/vec.rs reaches the editor as file://<cache>/prod-code/remote/<node>/srv/toolchains/std/vec.rs, read-only, with the node's content.

Each new test fails without its piece:

  • Python process id. With processId passed through, an_editors_python_server_does_not_watch_the_editors_process fails after 8.75 s: the session ended.
  • Save order. With the push before didSave removed, the CLI test fails. Its log shows no sync between initialize and didSave.

Which servers exit when processId names no process on the node (initialize with processId: 2147483000, 20 s):

rust-analyzer: still running after 20s
basedpyright-langserver: exited after 4s with 1
gopls: still running after 20s
clangd: still running after 20s

Checks (Linux build node)

  • cargo fmt --all -- --check: fmt=0 (after cargo fmt --all on the node)
  • cargo clippy -p prod-code-gateway -p prod-code-client --all-targets -- -D warnings: clippy=0
  • cargo llvm-cov -p prod-code-gateway -p prod-code-client: passed=177 failed=0
  • coverage (regions), by file:
    • editor_proxy.rs 89.60% (new)
    • editor_files.rs 96.54% (new)
    • gateway lib.rs 84.48%
    • workspace.rs 81.97%
    • client main.rs 80.91%
  • editors/zed: cargo build --release --target wasm32-wasip2 ok; cargo clippy --target wasm32-wasip2 -- -D warnings clean; cargo fmt --check clean.
  • In Zed itself. A second, isolated Zed cannot run next to a running one (zed is already running, whatever --user-data-dir says). The check in Zed follows the deploy, in a project that selects the servers in its .zed/settings.json.

Closes #332
Closes #333
Closes #334

@alex09x alex09x added enhancement New feature or request gateway Server daemon client CLI and MCP client labels Sep 25, 2026
@alex09x
alex09x merged commit a70c8bd into main Sep 25, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-25T08:02:34.935822Z 9c95a93 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@alex09x
alex09x deleted the feat/editor-servers branch September 25, 2026 07:59

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c95a930eb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +54 to +58
let from = |config: GenericLspConfig| ServerCommand {
program: config.command,
args: config.args,
env: config.env.into_iter().collect(),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the TypeScript server initialization options

When the node selects typescript-language-server and the checkout does not contain its own node_modules/typescript, GenericLspConfig::for_typescript() supplies the global tsserver.path needed by that server, but this conversion discards initialization_options. The dedicated editor process therefore starts successfully but cannot locate TypeScript unless the editor happens to provide a node-valid path itself; carry these defaults into the forwarded initialize request and merge them with the editor's options.

Useful? React with 👍 / 👎.

Comment on lines +3367 to +3372
match pushed {
Ok(()) => prod_code_mcp::watch::mark_synced(&cwd, generation),
Err(err) => {
tracing::debug!(error = %format!("{err:#}"), "sync before a save failed")
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop forwarding saves when their sync fails

If the separate sync connection transiently fails while the main LSP session remains alive, this branch only logs the error and immediately forwards textDocument/didSave. Rust-analyzer then runs its check-on-save against the previous on-disk contents even though the editor has just saved newer text, producing stale or misleading diagnostics; retry the sync or surface/terminate the session instead of delivering the save before its contents reach the node.

Useful? React with 👍 / 👎.

Comment on lines +103 to +107
let mirror = cache.join(remote.to_string().replace(':', "_"));
Self {
remote,
mirror_uri: format!("file://{}", mirror.display()),
mirror,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Encode the mirror root before matching file URIs

When HOME or XDG_CACHE_HOME contains a space, #, or another URL-escaped character, copy() returns a percent-encoded URI from Url::from_file_path, while mirror_uri and the early raw.contains(mirror) check use the unencoded filesystem string. Messages about an opened mirrored definition then bypass to_node, so the remote language server receives the client's cache path instead of the original node path and requests such as hover or references fail; construct and match the mirror prefix as a proper file URL.

Useful? React with 👍 / 👎.

@alex09x

alex09x commented Sep 25, 2026

Copy link
Copy Markdown
Owner Author

Live output after the deploy (gateway a70c8bd on a Linux build node; checked again on v0.3.9). A scripted editor session drives prod-code lsp in a one-file crate whose function moves v twice:

initialize 0.11s serverInfo={'name': 'rust-analyzer', 'version': '1.98.1 (48a229c 2026-09-01)'} sync={'openClose': True, 'change': 2, 'save': {}}
open src/lib.rs
hover src/lib.rs:0:20 0.24s -> {"kind": "plaintext", "value": "alloc::string\n\npub struct String {\n    vec: Vec<u8>,\n}\n\n\nA UTF-8–encoded, growable string. ..."}
rust-analyzer/expandMacro 0.01s -> {"name": "format!", "expansion": "alloc::__export::must_use({ alloc::fmt::format(alloc::__export::format_args!(\"{s:?}{t:?}\")) })\n"}
definition src/lib.rs:0:17 0.06s -> [{"range": {"end": {"character": 14, "line": 435}, "start": {"character": 11, "line": 435}}, "uri": "file:///Users/<you>/Library/Caches/prod-code/remote/<node>/home/<user>/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs"}]
notify textDocument/didSave
diagnostics: {"src/lib.rs": [[2, "use of moved value: `v`\nvalue used here after move"], [1, "value moved here"], [0, "move occurs because `v` has type `Vec<String>`, which does not implement the `Co"], [1, "consider cloning the value if the performance cost is acceptable: `.clone()`"]]}
server messages: {'textDocument/publishDiagnostics': 3, 'workspace/diagnostic/refresh': 2}

The home directories and the node's address in the mirror path are replaced by <you>, <user> and <node>. The mirrored copy is read-only (-r--r--r--, 168,574 bytes).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client CLI and MCP client enhancement New feature or request gateway Server daemon

Projects

None yet

1 participant