diff --git a/.github/workflows/release-proxy.yml b/.github/workflows/release-proxy.yml index 24a5628..731cbcb 100644 --- a/.github/workflows/release-proxy.yml +++ b/.github/workflows/release-proxy.yml @@ -51,8 +51,8 @@ jobs: # Build both native binaries from the workspace root. Selecting the two # packages explicitly keeps the WASM extension crate (`zed_java`, a - # cdylib) out of the native target build. The gradle-lsp-bridge gRPC - # bindings are committed, so no protoc toolchain is needed here. + # cdylib) out of the native target build. The Gradle bridge implements + # vscode-gradle 3.18's JSON-RPC transport over platform-local IPC. - name: Build binaries run: cargo build --release --target ${{ matrix.target }} -p java-lsp-proxy -p gradle-lsp-bridge shell: bash diff --git a/Cargo.lock b/Cargo.lock index ac86b03..96c422e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,23 +23,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "async-trait" -version = "0.1.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "auditable-serde" version = "0.8.0" @@ -205,12 +188,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -339,32 +316,12 @@ dependencies = [ name = "gradle-lsp-bridge" version = "6.8.23" dependencies = [ + "base64", "prost", "proxy-common", "serde", "serde_json", "tokio", - "tonic", - "tonic-prost", -] - -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", ] [[package]] @@ -394,106 +351,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "http" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -721,26 +578,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1024,12 +861,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - [[package]] name = "synstructure" version = "0.13.2" @@ -1091,138 +922,12 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "tokio-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tonic" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" -dependencies = [ - "async-trait", - "base64", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "socket2", - "sync_wrapper", - "tokio", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-prost" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" -dependencies = [ - "bytes", - "prost", - "tonic", -] - [[package]] name = "topological-sort" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "indexmap", - "pin-project-lite", - "slab", - "sync_wrapper", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - [[package]] name = "tree-sitter" version = "0.26.11" @@ -1253,12 +958,6 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typenum" version = "1.20.1" @@ -1301,15 +1000,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/README.md b/README.md index fd5fd61..fd3c2f8 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Here is a common `settings.json` including the above mentioned configurations: For **Groovy** build scripts (`.gradle`) the extension runs Microsoft's [Gradle Language Server](https://github.com/microsoft/vscode-gradle), giving you completions for Gradle DSL closures, plugin-contributed blocks (e.g. `java {}`, `application {}`), Maven Central dependency coordinates, and syntax diagnostics. -To resolve the *plugin-aware* parts of the model (which plugins are applied, the closures/methods they contribute, and the script classpath), the language server needs the resolved build model. The extension obtains this exactly the way the VS Code Gradle extension does: it drives the bundled `gradle-server` over gRPC via a small native binary, `gradle-lsp-bridge`. The bridge keeps a single `gradle-server` process (and its Gradle daemon) warm for the lifetime of the session, so re-syncs after a build-file save are fast. Both the language server and the bridge are downloaded and managed automatically — no configuration is required. +To resolve the *plugin-aware* parts of the model (which plugins are applied, the closures/methods they contribute, and the script classpath), the language server needs the resolved build model. The extension obtains this exactly the way vscode-gradle 3.18 does: a small native binary, `gradle-lsp-bridge`, drives the bundled `gradle-server` over JSON-RPC using a named pipe on Windows or a Unix-domain socket on Unix. Build-model messages are protobuf-encoded and carried as base64 payloads inside the JSON-RPC messages. The bridge keeps a single `gradle-server` process (and its Gradle daemon) warm for the lifetime of the session, so re-syncs after a build-file save are fast. Both the language server and the bridge are downloaded and managed automatically — no configuration is required. ### Kotlin DSL (`.gradle.kts`) @@ -631,7 +631,7 @@ If changes are not picked up, clean JDTLS' cache (from a java file run the task The extension uses two native binaries, both automatically downloaded from the [extension repository releases](https://github.com/zed-extensions/java/releases) and requiring no user configuration. Their managed versions are always bound to the extension version, so they do not use the 24-hour version cache and are not affected by `check_updates`. Explicit path settings and root-level development binaries installed with the `*-install` recipes still override the managed binaries: - **`java-lsp-proxy`** wraps the JDTLS process, enabling the extension to communicate with JDTLS for features like debug class resolution and classpath queries. -- **`gradle-lsp-bridge`** bridges Zed to the Gradle Language Server and drives the bundled `gradle-server` over gRPC to supply the resolved build model (see [Gradle Build Files](#gradle-build-files)). It pulls in an async/gRPC stack, so it is kept as a separate binary from the deliberately lean JDTLS proxy. +- **`gradle-lsp-bridge`** bridges Zed to the Gradle Language Server and drives vscode-gradle 3.18's bundled `gradle-server` over JSON-RPC using a named pipe on Windows or a Unix-domain socket on Unix. Protobuf-encoded build-model messages are carried as base64 payloads inside JSON-RPC (see [Gradle Build Files](#gradle-build-files)). It is kept as a separate binary from the deliberately lean JDTLS proxy. ## Developing Locally @@ -730,7 +730,7 @@ just proxy-release # or: just bridge-release When a path setting is provided, the extension uses that binary as-is and skips the managed download entirely — so there's no need to set `check_updates`. Rebuild and restart the language server to pick up changes. -> **Note:** The gRPC bindings the bridge uses are committed under `gradle-bridge/src/gen/`, so building it needs no `protoc`. They are regenerated only when the bundled Gradle Language Server's `gradle.proto` contract changes — see the header of `gradle-bridge/proto/gradle.proto`. +> **Note:** The bridge's protobuf message definitions mirror vscode-gradle 3.18's bundled `gradle.proto` contract. The schema is retained in `gradle-bridge/proto/gradle.proto`, with the corresponding Rust message types under `gradle-bridge/src/gen/`. ### Remote Development (SSH) diff --git a/gradle-bridge/Cargo.toml b/gradle-bridge/Cargo.toml index 53d229c..0b453f3 100644 --- a/gradle-bridge/Cargo.toml +++ b/gradle-bridge/Cargo.toml @@ -4,7 +4,7 @@ version = "6.8.23" edition = "2021" publish = false license = "Apache-2.0" -description = "Bridges Zed to the Gradle Language Server, driving the real gradle-server.jar over gRPC for plugin-aware completions" +description = "Bridges Zed to the Gradle Language Server, driving the real gradle-server.jar over JSON-RPC for plugin-aware completions" [[bin]] name = "gradle-lsp-bridge" @@ -14,7 +14,6 @@ path = "src/main.rs" proxy-common = { workspace = true, features = ["tokio"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +base64 = "0.22" prost = "0.14" -tonic = { version = "0.14", default-features = false, features = ["channel", "codegen", "transport"] } -tonic-prost = "0.14" tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "net", "process", "sync", "time", "fs"] } diff --git a/gradle-bridge/proto/gradle.proto b/gradle-bridge/proto/gradle.proto index c67a8c9..274f9d7 100644 --- a/gradle-bridge/proto/gradle.proto +++ b/gradle-bridge/proto/gradle.proto @@ -1,18 +1,14 @@ -// Source of truth: this file is a verbatim copy of `gradle.proto` embedded -// inside the shipped `gradle-server.jar` (vscode-gradle / package -// `com.github.badsyntax.gradle`), extracted with: +// Message schema adapted from `gradle.proto` embedded in vscode-gradle 3.18's +// shipped `gradle-server.jar` (package `com.github.badsyntax.gradle`), extracted +// with: // // unzip -p gradle-server.jar gradle.proto // -// The Rust bindings in `src/gen/gradle.rs` are generated from this file and -// COMMITTED, so neither CI nor contributors need `protoc`. When Microsoft ships -// a new gradle-server contract, re-extract this proto and regenerate: -// -// # from gradle-bridge/, with protoc on PATH: -// cargo run --quiet --bin gen-proto # (throwaway tonic-build helper), or -// protoc + tonic-build as documented in the build notes -// -// then commit both this file and the regenerated `src/gen/gradle.rs`. +// vscode-gradle 3.18 transports these protobuf messages as base64 payloads +// inside JSON-RPC over a named pipe on Windows or a Unix-domain socket on Unix. +// RPC service declarations are intentionally omitted because JSON-RPC defines +// the transport. The corresponding Rust message types live in +// `src/gen/gradle.rs`. // syntax = "proto3"; @@ -22,14 +18,6 @@ option java_outer_classname = "GradleProto"; package gradle; -service Gradle { - rpc GetBuild(GetBuildRequest) returns (stream GetBuildReply) {} - rpc RunBuild(RunBuildRequest) returns (stream RunBuildReply) {} - rpc CancelBuild(CancelBuildRequest) returns (CancelBuildReply) {} - rpc CancelBuilds(CancelBuildsRequest) returns (CancelBuildsReply) {} - rpc executeCommand(ExecuteCommandRequest) returns (ExecuteCommandReply) {} -} - message GetBuildRequest { string project_dir = 1; string cancellation_key = 2; @@ -48,6 +36,18 @@ message GetBuildReply { } } +message GetProjectDependenciesRequest { + string project_dir = 1; + string project_path = 2; + string cancellation_key = 3; + GradleConfig gradle_config = 4; + bool show_output_colors = 5; +} + +message GetProjectDependenciesReply { + DependencyItem dependency_item = 1; +} + message GetBuildResult { string message = 1; GradleBuild build = 2; @@ -66,19 +66,19 @@ message DependencyItem { repeated DependencyItem children = 3; } -message GrpcGradleClosure { +message GradleClosureProto { string name = 1; - repeated GrpcGradleMethod methods = 2; - repeated GrpcGradleField fields = 3; + repeated GradleMethodProto methods = 2; + repeated GradleFieldProto fields = 3; } -message GrpcGradleMethod { +message GradleMethodProto { string name = 1; repeated string parameterTypes = 2; bool deprecated = 3; } -message GrpcGradleField { +message GradleFieldProto { string name = 1; bool deprecated = 2; } @@ -142,7 +142,7 @@ message GradleProject { string projectPath = 4; DependencyItem dependencyItem = 5; repeated string plugins = 6; - repeated GrpcGradleClosure pluginClosures = 7; + repeated GradleClosureProto pluginClosures = 7; repeated string scriptClasspaths = 8; } diff --git a/gradle-bridge/src/channel.rs b/gradle-bridge/src/channel.rs index 05ca632..79b5d97 100644 --- a/gradle-bridge/src/channel.rs +++ b/gradle-bridge/src/channel.rs @@ -78,7 +78,7 @@ pub fn parse_publish_diagnostics(raw: &[u8]) -> Option<(String, Vec)> { /// Build the `uri -> [diagnostic]` map for a build-evaluation failure. /// -/// `error` is the top-level message (typically the gRPC `Status` message or a +/// `error` is the top-level message (typically a gradle-server build error or a /// `compatibility_check_error`); `causes` are appended line by line. The target /// file and line/column are parsed from the Gradle message when present /// (`build file '…': N:` and `@ line N, column C`), otherwise the diagnostic is diff --git a/gradle-bridge/src/gen/gradle.rs b/gradle-bridge/src/gen/gradle.rs index e58631f..3b0e326 100644 --- a/gradle-bridge/src/gen/gradle.rs +++ b/gradle-bridge/src/gen/gradle.rs @@ -15,6 +15,24 @@ pub struct GetBuildReply { #[prost(oneof = "get_build_reply::Kind", tags = "1, 2, 3, 4, 5, 6")] pub kind: ::core::option::Option, } +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetProjectDependenciesRequest { + #[prost(string, tag = "1")] + pub project_dir: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub project_path: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub cancellation_key: ::prost::alloc::string::String, + #[prost(message, optional, tag = "4")] + pub gradle_config: ::core::option::Option, + #[prost(bool, tag = "5")] + pub show_output_colors: bool, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetProjectDependenciesReply { + #[prost(message, optional, tag = "1")] + pub dependency_item: ::core::option::Option, +} /// Nested message and enum types in `GetBuildReply`. pub mod get_build_reply { #[derive(Clone, PartialEq, ::prost::Oneof)] @@ -50,16 +68,16 @@ pub struct DependencyItem { pub children: ::prost::alloc::vec::Vec, } #[derive(Clone, PartialEq, ::prost::Message)] -pub struct GrpcGradleClosure { +pub struct GradleClosureProto { #[prost(string, tag = "1")] pub name: ::prost::alloc::string::String, #[prost(message, repeated, tag = "2")] - pub methods: ::prost::alloc::vec::Vec, + pub methods: ::prost::alloc::vec::Vec, #[prost(message, repeated, tag = "3")] - pub fields: ::prost::alloc::vec::Vec, + pub fields: ::prost::alloc::vec::Vec, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct GrpcGradleMethod { +pub struct GradleMethodProto { #[prost(string, tag = "1")] pub name: ::prost::alloc::string::String, #[prost(string, repeated, tag = "2")] @@ -68,7 +86,7 @@ pub struct GrpcGradleMethod { pub deprecated: bool, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct GrpcGradleField { +pub struct GradleFieldProto { #[prost(string, tag = "1")] pub name: ::prost::alloc::string::String, #[prost(bool, tag = "2")] @@ -175,7 +193,7 @@ pub struct GradleProject { #[prost(string, repeated, tag = "6")] pub plugins: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, #[prost(message, repeated, tag = "7")] - pub plugin_closures: ::prost::alloc::vec::Vec, + pub plugin_closures: ::prost::alloc::vec::Vec, #[prost(string, repeated, tag = "8")] pub script_classpaths: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, } @@ -318,209 +336,3 @@ impl GradleDependencyType { } } } -/// Generated client implementations. -pub mod gradle_client { - #![allow( - unused_variables, - dead_code, - missing_docs, - clippy::wildcard_imports, - clippy::let_unit_value, - )] - use tonic::codegen::*; - use tonic::codegen::http::Uri; - #[derive(Debug, Clone)] - pub struct GradleClient { - inner: tonic::client::Grpc, - } - impl GradleClient { - /// Attempt to create a new client by connecting to a given endpoint. - pub async fn connect(dst: D) -> Result - where - D: TryInto, - D::Error: Into, - { - let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; - Ok(Self::new(conn)) - } - } - impl GradleClient - where - T: tonic::client::GrpcService, - T::Error: Into, - T::ResponseBody: Body + std::marker::Send + 'static, - ::Error: Into + std::marker::Send, - { - pub fn new(inner: T) -> Self { - let inner = tonic::client::Grpc::new(inner); - Self { inner } - } - pub fn with_origin(inner: T, origin: Uri) -> Self { - let inner = tonic::client::Grpc::with_origin(inner, origin); - Self { inner } - } - pub fn with_interceptor( - inner: T, - interceptor: F, - ) -> GradleClient> - where - F: tonic::service::Interceptor, - T::ResponseBody: Default, - T: tonic::codegen::Service< - http::Request, - Response = http::Response< - >::ResponseBody, - >, - >, - , - >>::Error: Into + std::marker::Send + std::marker::Sync, - { - GradleClient::new(InterceptedService::new(inner, interceptor)) - } - /// Compress requests with the given encoding. - /// - /// This requires the server to support it otherwise it might respond with an - /// error. - #[must_use] - pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.inner = self.inner.send_compressed(encoding); - self - } - /// Enable decompressing responses. - #[must_use] - pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.inner = self.inner.accept_compressed(encoding); - self - } - /// Limits the maximum size of a decoded message. - /// - /// Default: `4MB` - #[must_use] - pub fn max_decoding_message_size(mut self, limit: usize) -> Self { - self.inner = self.inner.max_decoding_message_size(limit); - self - } - /// Limits the maximum size of an encoded message. - /// - /// Default: `usize::MAX` - #[must_use] - pub fn max_encoding_message_size(mut self, limit: usize) -> Self { - self.inner = self.inner.max_encoding_message_size(limit); - self - } - pub async fn get_build( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response>, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static("/gradle.Gradle/GetBuild"); - let mut req = request.into_request(); - req.extensions_mut().insert(GrpcMethod::new("gradle.Gradle", "GetBuild")); - self.inner.server_streaming(req, path, codec).await - } - pub async fn run_build( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response>, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static("/gradle.Gradle/RunBuild"); - let mut req = request.into_request(); - req.extensions_mut().insert(GrpcMethod::new("gradle.Gradle", "RunBuild")); - self.inner.server_streaming(req, path, codec).await - } - pub async fn cancel_build( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/gradle.Gradle/CancelBuild", - ); - let mut req = request.into_request(); - req.extensions_mut().insert(GrpcMethod::new("gradle.Gradle", "CancelBuild")); - self.inner.unary(req, path, codec).await - } - pub async fn cancel_builds( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/gradle.Gradle/CancelBuilds", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert(GrpcMethod::new("gradle.Gradle", "CancelBuilds")); - self.inner.unary(req, path, codec).await - } - pub async fn execute_command( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic_prost::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/gradle.Gradle/executeCommand", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert(GrpcMethod::new("gradle.Gradle", "executeCommand")); - self.inner.unary(req, path, codec).await - } - } -} diff --git a/gradle-bridge/src/grpc.rs b/gradle-bridge/src/grpc.rs deleted file mode 100644 index d852cc6..0000000 --- a/gradle-bridge/src/grpc.rs +++ /dev/null @@ -1,506 +0,0 @@ -//! Drives the real shipped `gradle-server.jar` over gRPC, exactly as the VS Code -//! `vscode-gradle` extension does. -//! -//! A single long-lived `gradle-server` JVM is spawned once and kept alive for -//! the bridge's lifetime: it holds the Gradle Tooling-API connection open and -//! keeps the Gradle daemon warm, so re-syncs after the first are near-instant. -//! This is the whole point of driving the real server rather than forking a cold -//! JVM per save. -//! -//! The bridge calls the server-streaming `GetBuild` RPC, then maps the resulting -//! model into the `gradle.setPlugins` / `gradle.setClosures` / -//! `gradle.setScriptClasspaths` `executeCommand` arguments the Gradle Language -//! Server understands — the same forwarding the VS Code TypeScript client does. - -use std::env; -use std::process::Stdio; -use std::sync::Arc; -use std::time::Duration; - -use serde_json::{json, Value}; -use tokio::net::TcpListener; -use tokio::process::{Child, Command}; -use tokio::sync::Mutex; -use tokio::time::sleep; -use tonic::transport::{Channel, Endpoint}; - -use crate::proto::gradle::{ - get_build_reply::Kind, gradle_client::GradleClient, output::OutputType, GetBuildRequest, - GradleConfig, GradleProject, -}; - -/// The `java_extension_version` reported to `gradle-server` in `GradleConfig`. -/// It only has to clear the server's `isAtLeast("1.3.0")` compatibility gate; -/// we report the vscode-gradle line we drive the protocol against. -const JAVA_EXTENSION_VERSION: &str = "3.17.3"; - -/// The Gradle distribution configuration forwarded by the extension via -/// environment variables and threaded into the gRPC `GradleConfig`. Mirrors the -/// knobs the VS Code `gradle-server` honors. -#[derive(Clone, Default)] -pub struct DistributionConfig { - pub gradle_user_home: String, - pub gradle_home: String, - pub version: String, - pub jvm_arguments: String, - pub java_home: String, - pub wrapper_enabled: bool, -} - -impl DistributionConfig { - /// Read the configuration the extension exported as `GRADLE_SYNC_*` vars. - /// Absence of `GRADLE_SYNC_WRAPPER_ENABLED=false` means wrapper-enabled (the - /// default), matching the helper's previous behavior and the LS settings. - pub fn from_env() -> Self { - let wrapper_enabled = env::var("GRADLE_SYNC_WRAPPER_ENABLED") - .map(|v| !v.eq_ignore_ascii_case("false")) - .unwrap_or(true); - Self { - gradle_user_home: env_or_empty("GRADLE_SYNC_USER_HOME"), - gradle_home: env_or_empty("GRADLE_SYNC_GRADLE_HOME"), - version: env_or_empty("GRADLE_SYNC_VERSION"), - jvm_arguments: env_or_empty("GRADLE_SYNC_JVM_ARGS"), - java_home: env_or_empty("GRADLE_SYNC_JAVA_HOME"), - wrapper_enabled, - } - } - - fn to_gradle_config(&self) -> GradleConfig { - GradleConfig { - gradle_home: self.gradle_home.clone(), - user_home: self.gradle_user_home.clone(), - jvm_arguments: self.jvm_arguments.clone(), - wrapper_enabled: self.wrapper_enabled, - version: self.version.clone(), - // gradle-server runs a compatibility gate as `new Version(value) - // .isAtLeast("1.3.0")` (verified in GetBuildHandler), and an empty - // string would construct an invalid Version. Report a value at/above - // that floor, mirroring the real vscode-gradle extension which sends - // its own version here. - java_extension_version: JAVA_EXTENSION_VERSION.to_string(), - java_home: self.java_home.clone(), - } - } -} - -fn env_or_empty(key: &str) -> String { - env::var(key).unwrap_or_default() -} - -/// The outcome of a `GetBuild` call: either the resolved root project model, or -/// a build-evaluation failure to surface as a diagnostic on the build file. -pub enum BuildOutcome { - Model(GradleProject), - /// `(error, causes)` — already flattened from the gRPC status / reply. - Error { - error: String, - causes: Vec, - }, -} - -/// Manages the long-lived `gradle-server` process and gRPC channel. Cloneable -/// (cheap `Arc` clone) so it can be shared with the sync worker. -#[derive(Clone)] -pub struct GradleServer { - inner: Arc>, - java: String, - classpath: String, - java_home: Option, - config: DistributionConfig, -} - -struct ServerState { - /// The running server process + connected channel, if started. - running: Option, -} - -struct RunningServer { - child: Child, - channel: Channel, -} - -impl GradleServer { - pub fn new( - java: String, - classpath: String, - java_home: Option, - config: DistributionConfig, - ) -> Self { - Self { - inner: Arc::new(Mutex::new(ServerState { running: None })), - java, - classpath, - java_home, - config, - } - } - - /// Run `GetBuild` for `project_dir`, starting the server on first use and - /// reusing it thereafter. `cancellation_key` is echoed in the request so a - /// superseding sync can cancel this build via [`Self::cancel`]. - pub async fn get_build(&self, project_dir: &str, cancellation_key: &str) -> BuildOutcome { - let channel = match self.ensure_channel().await { - Ok(c) => c, - Err(e) => { - return BuildOutcome::Error { - error: format!("Failed to start gradle-server: {e}"), - causes: Vec::new(), - }; - } - }; - - let mut client = GradleClient::new(channel) - // Multi-project models can exceed the 4 MB default; the VS Code - // client sets this to unlimited. - .max_decoding_message_size(usize::MAX); - - let request = GetBuildRequest { - project_dir: project_dir.to_string(), - cancellation_key: cancellation_key.to_string(), - gradle_config: Some(self.config.to_gradle_config()), - show_output_colors: false, - }; - - let mut stream = match client.get_build(request).await { - Ok(resp) => resp.into_inner(), - Err(status) => { - return BuildOutcome::Error { - error: status.message().to_string(), - causes: Vec::new(), - }; - } - }; - - // The gRPC error status carries only the outermost exception message - // (`ErrorMessageBuilder` sets `Status.INTERNAL.withDescription(e.getMessage())`) - // — e.g. "The supplied build action failed with an exception." The - // actionable detail (the offending build file, line/column, and the root - // cause) is what Gradle writes to standard error, which the server - // streams back as `Output` messages with `output_type = STDERR`. We - // accumulate that here and attach it to the failure so it reaches the - // editor diagnostic, instead of discarding it. - let mut model: Option = None; - let mut stderr = String::new(); - loop { - match stream.message().await { - Ok(Some(reply)) => match reply.kind { - Some(Kind::GetBuildResult(result)) => { - model = result.build.and_then(|b| b.project); - } - Some(Kind::CompatibilityCheckError(msg)) => { - return BuildOutcome::Error { - error: msg, - causes: stderr_causes(&stderr), - }; - } - Some(Kind::Output(output)) - if output.output_type == OutputType::Stderr as i32 => - { - stderr.push_str(&String::from_utf8_lossy(&output.output_bytes)); - } - // Progress/Environment/Cancelled are informational. - _ => {} - }, - Ok(None) => break, - Err(status) => { - return BuildOutcome::Error { - error: status.message().to_string(), - causes: stderr_causes(&stderr), - }; - } - } - } - - match model { - Some(project) => BuildOutcome::Model(project), - None => BuildOutcome::Error { - error: "gradle-server returned no build model".to_string(), - causes: stderr_causes(&stderr), - }, - } - } - - /// Cancel an in-flight build identified by `cancellation_key`. Best-effort: - /// errors (including the server not running) are ignored. - pub async fn cancel(&self, cancellation_key: &str) { - let channel = { - let state = self.inner.lock().await; - state.running.as_ref().map(|r| r.channel.clone()) - }; - let Some(channel) = channel else { - return; - }; - let mut client = GradleClient::new(channel); - let _ = client - .cancel_build(crate::proto::gradle::CancelBuildRequest { - cancellation_key: cancellation_key.to_string(), - }) - .await; - } - - /// Kill the server process if running. Called on bridge shutdown. - pub async fn shutdown(&self) { - let mut state = self.inner.lock().await; - if let Some(mut running) = state.running.take() { - let _ = running.child.start_kill(); - } - } - - /// Ensure a connected channel exists, (re)starting the server if needed. - async fn ensure_channel(&self) -> Result { - let mut state = self.inner.lock().await; - - // Reuse a healthy running server. - if let Some(running) = state.running.as_mut() { - // If the JVM died, drop it and restart below. - match running.child.try_wait() { - Ok(None) => return Ok(running.channel.clone()), - _ => { - state.running = None; - } - } - } - - let port = free_port().await?; - let child = self.spawn_server(port)?; - let channel = connect_with_retry(port).await?; - state.running = Some(RunningServer { - child, - channel: channel.clone(), - }); - Ok(channel) - } - - /// Spawn `java -cp com.github.badsyntax.gradle.GradleServer `. - fn spawn_server(&self, port: u16) -> Result { - let mut cmd = Command::new(&self.java); - // GradleServer.main parses only `--key=value` args (Utils.parseArgs); a - // bare positional port is ignored. `port` is required; `startBuildServer` - // is also validated as required — we set it false because we only need - // the gRPC build-model server, not the BSP build server (which would in - // turn require `pipeName`/`bundleDir`). The LS pipe path is omitted: the - // bridge launches and talks to the language server itself. - cmd.args([ - "-Dfile.encoding=UTF-8", - "-cp", - &self.classpath, - "com.github.badsyntax.gradle.GradleServer", - &format!("--port={port}"), - "--startBuildServer=false", - ]) - .stdin(Stdio::null()) - // The server logs readiness to stderr; inherit so it lands in the - // bridge's own stderr (Zed's language server log) for debugging. - .stdout(Stdio::null()) - .stderr(Stdio::inherit()) - .kill_on_drop(true); - - if let Some(home) = &self.java_home { - // The Gradle start script honors VSCODE_JAVA_HOME; set JAVA_HOME too - // so the directly-launched server uses the same JDK. - cmd.env("JAVA_HOME", home); - cmd.env("VSCODE_JAVA_HOME", home); - } - - cmd.spawn() - .map_err(|e| format!("failed to spawn gradle-server: {e}")) - } -} - -/// Turn the captured Gradle standard-error text into a list of cause lines. -/// -/// The diagnostics builder joins these onto the top-level error message and -/// scans the combined text for Gradle's `build file '…': N:` and -/// `@ line N, column C` markers, so preserving the raw lines keeps both the -/// human-readable detail and the location parsing intact. Returns empty when no -/// stderr was captured (a successful build, or a failure that wrote nothing). -fn stderr_causes(stderr: &str) -> Vec { - let trimmed = stderr.trim(); - if trimmed.is_empty() { - return Vec::new(); - } - trimmed - .lines() - .map(str::trim_end) - .filter(|line| !line.is_empty()) - .map(str::to_string) - .collect() -} - -/// Pick a free TCP port on the loopback interface. We bind, read the assigned -/// port, then drop the listener so the JVM can bind it. (A brief race window -/// exists, but the loopback ephemeral range makes a collision very unlikely; the -/// connect-retry below also absorbs a transient failure.) -async fn free_port() -> Result { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .map_err(|e| format!("failed to reserve a port: {e}"))?; - let port = listener - .local_addr() - .map_err(|e| format!("failed to read reserved port: {e}"))? - .port(); - Ok(port) -} - -/// Connect a plaintext h2c channel to the server, retrying while the JVM starts -/// up and binds its port. Mirrors the VS Code client's `waitForReady` deadline. -async fn connect_with_retry(port: u16) -> Result { - let uri = format!("http://127.0.0.1:{port}"); - let endpoint = Endpoint::from_shared(uri) - .map_err(|e| format!("invalid gradle-server endpoint: {e}"))? - .connect_timeout(Duration::from_secs(2)); - - // Up to ~30s total, matching the VS Code client's readiness deadline. - let mut last_err = String::new(); - for _ in 0..150 { - match endpoint.connect().await { - Ok(channel) => return Ok(channel), - Err(e) => { - last_err = e.to_string(); - sleep(Duration::from_millis(200)).await; - } - } - } - Err(format!("gradle-server did not become ready: {last_err}")) -} - -/// Build the ordered list of `workspace/executeCommand` argument tuples to send -/// to the language server for `root` and every subproject, recursively. -/// -/// Each entry is `(command, arguments)` where `arguments` is the JSON array the -/// LS expects. The `projectPath` argument is normalized to match the key the LS -/// derives from a document URI (`Paths.get(uri).getParent().toString()`), i.e. -/// the absolute project directory — which is exactly what the model reports. -pub fn model_to_commands(root: &GradleProject) -> Vec<(&'static str, Value)> { - let mut commands = Vec::new(); - collect_commands(root, &mut commands); - commands -} - -fn collect_commands(project: &GradleProject, out: &mut Vec<(&'static str, Value)>) { - let project_path = normalize_project_path(&project.project_path); - - // gradle.setPlugins [projectPath, plugins[]] - out.push(("gradle.setPlugins", json!([project_path, project.plugins]))); - - // gradle.setClosures [projectPath, closures[]] - let closures: Vec = project - .plugin_closures - .iter() - .map(|closure| { - let methods: Vec = closure - .methods - .iter() - .map(|m| { - json!({ - "name": m.name, - "parameterTypes": m.parameter_types, - "deprecated": m.deprecated, - }) - }) - .collect(); - let fields: Vec = closure - .fields - .iter() - .map(|f| json!({ "name": f.name, "deprecated": f.deprecated })) - .collect(); - json!({ "name": closure.name, "methods": methods, "fields": fields }) - }) - .collect(); - out.push(("gradle.setClosures", json!([project_path, closures]))); - - // gradle.setScriptClasspaths [projectPath, scriptClasspaths[]] - out.push(( - "gradle.setScriptClasspaths", - json!([project_path, project.script_classpaths]), - )); - - for sub in &project.projects { - collect_commands(sub, out); - } -} - -/// Normalize an absolute project path so it matches the key the language server -/// derives via `Paths.get(uri).getParent().toString()` — collapsing redundant -/// separators and `.`/`..` segments without resolving symlinks. -fn normalize_project_path(path: &str) -> String { - use std::path::{Component, PathBuf}; - - let mut normalized = PathBuf::new(); - for component in std::path::Path::new(path).components() { - match component { - Component::ParentDir => { - normalized.pop(); - } - Component::CurDir => {} - other => normalized.push(other.as_os_str()), - } - } - normalized.to_string_lossy().to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::proto::gradle::{GrpcGradleClosure, GrpcGradleField, GrpcGradleMethod}; - - fn sample_project() -> GradleProject { - GradleProject { - is_root: true, - tasks: vec![], - projects: vec![GradleProject { - is_root: false, - project_path: "/p/sub".to_string(), - plugins: vec!["java".to_string()], - ..Default::default() - }], - project_path: "/p".to_string(), - dependency_item: None, - plugins: vec!["java".to_string(), "application".to_string()], - plugin_closures: vec![GrpcGradleClosure { - name: "java".to_string(), - methods: vec![GrpcGradleMethod { - name: "sourceCompatibility".to_string(), - parameter_types: vec!["String".to_string()], - deprecated: false, - }], - fields: vec![GrpcGradleField { - name: "sourceSets".to_string(), - deprecated: false, - }], - }], - script_classpaths: vec!["/p/.gradle/x.jar".to_string()], - } - } - - #[test] - fn emits_three_commands_per_project_recursively() { - let cmds = model_to_commands(&sample_project()); - // root + 1 subproject, 3 commands each. - assert_eq!(cmds.len(), 6); - assert_eq!(cmds[0].0, "gradle.setPlugins"); - assert_eq!(cmds[1].0, "gradle.setClosures"); - assert_eq!(cmds[2].0, "gradle.setScriptClasspaths"); - // Root projectPath is arg 0 of setPlugins. - assert_eq!(cmds[0].1[0], "/p"); - assert_eq!(cmds[0].1[1][0], "java"); - // Subproject follows. - assert_eq!(cmds[3].1[0], "/p/sub"); - } - - #[test] - fn closure_shape_matches_ls_contract() { - let cmds = model_to_commands(&sample_project()); - let closures = &cmds[1].1[1]; - assert_eq!(closures[0]["name"], "java"); - assert_eq!(closures[0]["methods"][0]["name"], "sourceCompatibility"); - assert_eq!(closures[0]["methods"][0]["parameterTypes"][0], "String"); - assert_eq!(closures[0]["methods"][0]["deprecated"], false); - assert_eq!(closures[0]["fields"][0]["name"], "sourceSets"); - } - - #[test] - fn normalizes_dot_segments() { - assert_eq!(normalize_project_path("/p/./sub/../sub"), "/p/sub"); - } -} diff --git a/gradle-bridge/src/jsonrpc.rs b/gradle-bridge/src/jsonrpc.rs new file mode 100644 index 0000000..956a264 --- /dev/null +++ b/gradle-bridge/src/jsonrpc.rs @@ -0,0 +1,890 @@ +//! Minimal JSON-RPC 2.0 client for the Gradle task server. +//! +//! vscode-gradle 3.18 keeps protobuf as the message schema, but carries encoded +//! protobuf bytes inside JSON-RPC envelopes framed with LSP `Content-Length` +//! headers. Only the methods used by this bridge are exposed here. + +use std::collections::HashMap; +use std::fmt; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine as _; +use prost::Message; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; +use tokio::sync::{mpsc, oneshot, watch, Mutex}; + +use proxy_common::{encode_lsp, parse_lsp_content, AsyncLspReader}; + +const GET_BUILD_REPLY: &str = "gradle/getBuild/reply"; + +#[derive(Clone, Debug)] +pub struct RpcError { + pub code: Option, + pub message: String, + pub data: Option, + connection: bool, +} + +impl RpcError { + fn connection(message: impl Into) -> Self { + Self { + code: None, + message: message.into(), + data: None, + connection: true, + } + } + + pub fn is_connection(&self) -> bool { + self.connection + } +} + +impl fmt::Display for RpcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for RpcError {} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GradleRequestParams { + request: String, + stream_id: Option, +} + +#[derive(Deserialize)] +struct GradleResponse { + reply: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GradleStreamPayload { + stream_id: u64, + payload: String, +} + +type PendingSender = oneshot::Sender>; + +struct ClientInner { + writer: Mutex>>, + pending: Mutex>, + get_build_streams: Mutex>>>, + next_request_id: AtomicU64, + next_stream_id: AtomicU64, + closed: AtomicBool, + closed_tx: watch::Sender, +} + +/// A cloneable client for one task-pipe connection. +#[derive(Clone)] +pub struct GradleJsonRpcClient { + inner: Arc, +} + +impl GradleJsonRpcClient { + pub fn new(reader: R, writer: W) -> Self + where + R: AsyncRead + Send + Unpin + 'static, + W: AsyncWrite + Send + Unpin + 'static, + { + let (closed_tx, _) = watch::channel(false); + let inner = Arc::new(ClientInner { + writer: Mutex::new(Some(Box::new(writer))), + pending: Mutex::new(HashMap::new()), + get_build_streams: Mutex::new(HashMap::new()), + next_request_id: AtomicU64::new(1), + next_stream_id: AtomicU64::new(1), + closed: AtomicBool::new(false), + closed_tx, + }); + tokio::spawn(read_loop(reader, Arc::clone(&inner))); + Self { inner } + } + + pub fn is_closed(&self) -> bool { + self.inner.closed.load(Ordering::Acquire) + } + + pub fn subscribe_closed(&self) -> watch::Receiver { + self.inner.closed_tx.subscribe() + } + + pub async fn close(&self, message: impl Into) { + close_connection(&self.inner, message).await; + } + + /// Register a sink before sending `gradle/getBuild`, preventing an early + /// server notification from racing ahead of stream registration. + pub async fn register_get_build_stream(&self) -> (u64, mpsc::UnboundedReceiver>) { + let stream_id = self.inner.next_stream_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::unbounded_channel(); + self.inner + .get_build_streams + .lock() + .await + .insert(stream_id, tx); + (stream_id, rx) + } + + pub async fn remove_get_build_stream(&self, stream_id: u64) { + self.inner.get_build_streams.lock().await.remove(&stream_id); + } + + /// Send a Gradle JSON-RPC request, encoding `request` as protobuf and + /// decoding the optional protobuf reply bytes from the response envelope. + pub async fn request( + &self, + method: &str, + request: &M, + stream_id: Option, + ) -> Result>, RpcError> { + let id = self.inner.next_request_id.fetch_add(1, Ordering::Relaxed); + let params = GradleRequestParams { + request: BASE64.encode(request.encode_to_vec()), + stream_id, + }; + let message = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + let framed = encode_lsp(&message); + let (tx, rx) = oneshot::channel(); + let mut closed = self.inner.closed_tx.subscribe(); + { + let mut pending = self.inner.pending.lock().await; + if self.is_closed() { + return Err(RpcError::connection("Gradle JSON-RPC connection is closed")); + } + pending.insert(id, tx); + } + + let write_result = tokio::select! { + result = write_request(&self.inner, framed.as_bytes()) => result, + () = wait_for_close(&self.inner, &mut closed) => { + Err(RpcError::connection( + "Gradle JSON-RPC connection closed while writing request", + )) + } + }; + if let Err(error) = write_result { + let message = error.message.clone(); + close_connection(&self.inner, message.clone()).await; + return Err(error); + } + + let result = rx.await.map_err(|_| { + RpcError::connection("Gradle JSON-RPC connection closed before request completed") + })??; + if result.is_null() { + return Ok(None); + } + let response: GradleResponse = match serde_json::from_value(result) { + Ok(response) => response, + Err(error) => { + let message = format!("Invalid Gradle JSON-RPC response: {error}"); + self.close(message.clone()).await; + return Err(RpcError::connection(message)); + } + }; + let Some(reply) = response.reply else { + return Ok(None); + }; + match BASE64.decode(reply) { + Ok(reply) => Ok(Some(reply)), + Err(error) => { + let message = format!("Invalid base64 Gradle protobuf response: {error}"); + self.close(message.clone()).await; + Err(RpcError::connection(message)) + } + } + } +} + +async fn write_request(inner: &ClientInner, framed: &[u8]) -> Result<(), RpcError> { + let mut writer = inner.writer.lock().await; + if inner.closed.load(Ordering::Acquire) { + return Err(RpcError::connection("Gradle JSON-RPC connection is closed")); + } + let writer = writer + .as_mut() + .ok_or_else(|| RpcError::connection("Gradle JSON-RPC writer is closed"))?; + let result = match writer.write_all(framed).await { + Ok(()) => writer.flush().await, + Err(error) => Err(error), + }; + result.map_err(|error| { + RpcError::connection(format!("Failed to write Gradle JSON-RPC request: {error}")) + }) +} + +async fn wait_for_close(inner: &ClientInner, closed: &mut watch::Receiver) { + loop { + if inner.closed.load(Ordering::Acquire) || *closed.borrow() { + return; + } + if closed.changed().await.is_err() { + return; + } + } +} + +async fn read_loop(reader: R, inner: Arc) +where + R: AsyncRead + Unpin, +{ + let mut reader = AsyncLspReader::new(reader); + let mut closed = inner.closed_tx.subscribe(); + loop { + if inner.closed.load(Ordering::Acquire) { + return; + } + let read_result = tokio::select! { + result = reader.read_message() => result, + changed = closed.changed() => { + if changed.is_err() || *closed.borrow() { + return; + } + continue; + } + }; + let raw = match read_result { + Ok(Some(raw)) => raw, + Ok(None) => { + close_connection(&inner, "Gradle JSON-RPC connection closed").await; + return; + } + Err(error) => { + close_connection( + &inner, + format!("Failed to read Gradle JSON-RPC response: {error}"), + ) + .await; + return; + } + }; + let Some(message) = parse_lsp_content(&raw) else { + close_connection(&inner, "Invalid JSON on Gradle JSON-RPC connection").await; + return; + }; + if message.get("method").and_then(Value::as_str) == Some(GET_BUILD_REPLY) { + if let Err(error) = dispatch_get_build_reply(&inner, &message).await { + close_connection(&inner, error).await; + return; + } + continue; + } + + if message.get("method").is_some() { + continue; + } + let has_result = message.get("result").is_some(); + let has_error = message.get("error").is_some(); + if has_result == has_error { + close_connection( + &inner, + "Invalid Gradle JSON-RPC response: expected exactly one of result or error", + ) + .await; + return; + } + let Some(id) = message.get("id").and_then(Value::as_u64) else { + close_connection( + &inner, + "Invalid Gradle JSON-RPC response: missing numeric id", + ) + .await; + return; + }; + let sender = inner.pending.lock().await.remove(&id); + let Some(sender) = sender else { + close_connection( + &inner, + format!("Invalid Gradle JSON-RPC response: unknown id {id}"), + ) + .await; + return; + }; + if let Some(error) = message.get("error") { + let _ = sender.send(Err(parse_rpc_error(error))); + } else { + let _ = sender.send(Ok(message.get("result").cloned().unwrap_or(Value::Null))); + } + } +} + +async fn dispatch_get_build_reply(inner: &ClientInner, message: &Value) -> Result<(), String> { + let params = message + .get("params") + .cloned() + .ok_or_else(|| "Gradle getBuild reply is missing params".to_string())?; + let payload = serde_json::from_value::(params) + .map_err(|error| format!("Invalid Gradle getBuild reply: {error}"))?; + let bytes = BASE64 + .decode(payload.payload) + .map_err(|error| format!("Invalid base64 Gradle getBuild reply: {error}"))?; + if let Some(sender) = inner.get_build_streams.lock().await.get(&payload.stream_id) { + let _ = sender.send(bytes); + } + Ok(()) +} + +fn parse_rpc_error(error: &Value) -> RpcError { + RpcError { + code: error.get("code").and_then(Value::as_i64), + message: error + .get("message") + .and_then(Value::as_str) + .unwrap_or("Gradle JSON-RPC request failed") + .to_string(), + data: error.get("data").cloned(), + connection: false, + } +} + +async fn close_connection(inner: &Arc, message: impl Into) { + if inner.closed.swap(true, Ordering::AcqRel) { + return; + } + let message = message.into(); + let _ = inner.closed_tx.send(true); + let inner = Arc::clone(inner); + let cleanup = tokio::spawn(async move { + let pending = std::mem::take(&mut *inner.pending.lock().await); + for (_, sender) in pending { + let _ = sender.send(Err(RpcError::connection(message.clone()))); + } + inner.get_build_streams.lock().await.clear(); + inner.writer.lock().await.take(); + }); + let _ = cleanup.await; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::gradle::{CancelBuildReply, CancelBuildRequest, GetBuildReply, Progress}; + use prost::Message; + use tokio::io::{duplex, split, AsyncWriteExt}; + + struct DropTrackingReader { + dropped: Option>, + } + + impl Drop for DropTrackingReader { + fn drop(&mut self) { + if let Some(dropped) = self.dropped.take() { + let _ = dropped.send(()); + } + } + } + + impl AsyncRead for DropTrackingReader { + fn poll_read( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + _buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + } + + struct DropTrackingWriter { + started: Option>, + dropped: Option>, + block_writes: bool, + } + + impl Drop for DropTrackingWriter { + fn drop(&mut self) { + if let Some(dropped) = self.dropped.take() { + let _ = dropped.send(()); + } + } + } + + impl AsyncWrite for DropTrackingWriter { + fn poll_write( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + if let Some(started) = &self.started { + started.notify_one(); + } + if self.block_writes { + std::task::Poll::Pending + } else { + std::task::Poll::Ready(Ok(buf.len())) + } + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + } + + async fn send_json(writer: &mut (impl AsyncWrite + Unpin), value: Value) { + writer + .write_all(encode_lsp(&value).as_bytes()) + .await + .unwrap(); + writer.flush().await.unwrap(); + } + + #[tokio::test] + async fn sends_proto_request_and_decodes_reply() { + let (client_side, server_side) = duplex(64 * 1024); + let (client_read, client_write) = split(client_side); + let (server_read, mut server_write) = split(server_side); + let client = GradleJsonRpcClient::new(client_read, client_write); + + let server = tokio::spawn(async move { + let mut reader = AsyncLspReader::new(server_read); + let raw = reader.read_message().await.unwrap().unwrap(); + let request = parse_lsp_content(&raw).unwrap(); + assert_eq!(request["method"], "gradle/cancelBuild"); + let encoded = request["params"]["request"].as_str().unwrap(); + let decoded = BASE64.decode(encoded).unwrap(); + let request_proto = CancelBuildRequest::decode(decoded.as_slice()).unwrap(); + assert_eq!(request_proto.cancellation_key, "sync-1"); + + let reply = CancelBuildReply { + message: "cancelled".to_string(), + build_running: true, + }; + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { "reply": BASE64.encode(reply.encode_to_vec()) } + }), + ) + .await; + }); + + let bytes = client + .request( + "gradle/cancelBuild", + &CancelBuildRequest { + cancellation_key: "sync-1".to_string(), + }, + None, + ) + .await + .unwrap() + .unwrap(); + let reply = CancelBuildReply::decode(bytes.as_slice()).unwrap(); + assert!(reply.build_running); + server.await.unwrap(); + } + + #[tokio::test] + async fn routes_stream_notification_before_terminal_response() { + let (client_side, server_side) = duplex(64 * 1024); + let (client_read, client_write) = split(client_side); + let (server_read, mut server_write) = split(server_side); + let client = GradleJsonRpcClient::new(client_read, client_write); + let (stream_id, mut stream) = client.register_get_build_stream().await; + + let server = tokio::spawn(async move { + let mut reader = AsyncLspReader::new(server_read); + let raw = reader.read_message().await.unwrap().unwrap(); + let request = parse_lsp_content(&raw).unwrap(); + let progress = GetBuildReply { + kind: Some(crate::proto::gradle::get_build_reply::Kind::Progress( + Progress { + message: "Configure project".to_string(), + }, + )), + }; + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "method": GET_BUILD_REPLY, + "params": { + "streamId": stream_id, + "payload": BASE64.encode(progress.encode_to_vec()) + } + }), + ) + .await; + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { "reply": Value::Null } + }), + ) + .await; + }); + + let _ = client + .request( + "gradle/getBuild", + &crate::proto::gradle::GetBuildRequest::default(), + Some(stream_id), + ) + .await + .unwrap(); + let bytes = stream.recv().await.unwrap(); + let reply = GetBuildReply::decode(bytes.as_slice()).unwrap(); + assert!(matches!( + reply.kind, + Some(crate::proto::gradle::get_build_reply::Kind::Progress(_)) + )); + server.await.unwrap(); + } + + #[tokio::test] + async fn rejects_pending_request_when_connection_closes() { + let (client_side, server_side) = duplex(1024); + let (client_read, client_write) = split(client_side); + let (server_read, server_write) = split(server_side); + let client = GradleJsonRpcClient::new(client_read, client_write); + + let request = tokio::spawn({ + let client = client.clone(); + async move { + client + .request("gradle/cancelBuild", &CancelBuildRequest::default(), None) + .await + } + }); + let mut reader = AsyncLspReader::new(server_read); + reader.read_message().await.unwrap().unwrap(); + drop(reader); + drop(server_write); + + let error = tokio::time::timeout(std::time::Duration::from_secs(1), request) + .await + .expect("request should fail promptly when the connection closes") + .unwrap() + .unwrap_err(); + assert!(error.message.contains("closed")); + assert!(error.is_connection()); + } + + #[tokio::test] + async fn client_close_sends_peer_eof() { + let (client_side, server_side) = duplex(1024); + let (client_read, client_write) = split(client_side); + let (server_read, _server_write) = split(server_side); + let client = GradleJsonRpcClient::new(client_read, client_write); + let mut reader = AsyncLspReader::new(server_read); + + client.close("protocol error").await; + + let message = + tokio::time::timeout(std::time::Duration::from_secs(1), reader.read_message()) + .await + .expect("peer should observe EOF promptly") + .unwrap(); + assert!(message.is_none()); + } + + #[tokio::test] + async fn client_close_stops_reader_task() { + let (reader_dropped_tx, reader_dropped_rx) = oneshot::channel(); + let client = GradleJsonRpcClient::new( + DropTrackingReader { + dropped: Some(reader_dropped_tx), + }, + tokio::io::sink(), + ); + + client.close("protocol error").await; + + tokio::time::timeout(std::time::Duration::from_secs(1), reader_dropped_rx) + .await + .expect("reader task should stop promptly") + .unwrap(); + } + + #[tokio::test] + async fn client_close_drops_writer_with_noop_shutdown() { + let (reader_dropped_tx, _reader_dropped_rx) = oneshot::channel(); + let (writer_dropped_tx, writer_dropped_rx) = oneshot::channel(); + let client = GradleJsonRpcClient::new( + DropTrackingReader { + dropped: Some(reader_dropped_tx), + }, + DropTrackingWriter { + started: None, + dropped: Some(writer_dropped_tx), + block_writes: false, + }, + ); + + client.close("protocol error").await; + + tokio::time::timeout(std::time::Duration::from_secs(1), writer_dropped_rx) + .await + .expect("writer should be dropped promptly") + .unwrap(); + } + + #[tokio::test] + async fn client_close_cancels_blocked_write() { + let (reader_dropped_tx, _reader_dropped_rx) = oneshot::channel(); + let (writer_dropped_tx, writer_dropped_rx) = oneshot::channel(); + let write_started = Arc::new(tokio::sync::Notify::new()); + let client = GradleJsonRpcClient::new( + DropTrackingReader { + dropped: Some(reader_dropped_tx), + }, + DropTrackingWriter { + started: Some(Arc::clone(&write_started)), + dropped: Some(writer_dropped_tx), + block_writes: true, + }, + ); + let request = tokio::spawn({ + let client = client.clone(); + async move { + client + .request("gradle/cancelBuild", &CancelBuildRequest::default(), None) + .await + } + }); + write_started.notified().await; + + tokio::time::timeout( + std::time::Duration::from_secs(1), + client.close("protocol error"), + ) + .await + .expect("close should not wait for a blocked write"); + + let error = request.await.unwrap().unwrap_err(); + assert!(error.is_connection()); + writer_dropped_rx.await.unwrap(); + } + + #[tokio::test] + async fn malformed_response_closes_connection() { + let (client_side, server_side) = duplex(1024); + let (client_read, client_write) = split(client_side); + let (server_read, mut server_write) = split(server_side); + let client = GradleJsonRpcClient::new(client_read, client_write); + + let request = tokio::spawn({ + let client = client.clone(); + async move { + client + .request("gradle/cancelBuild", &CancelBuildRequest::default(), None) + .await + } + }); + let mut reader = AsyncLspReader::new(server_read); + let raw = reader.read_message().await.unwrap().unwrap(); + let message = parse_lsp_content(&raw).unwrap(); + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "id": message["id"], + "result": "not a Gradle response" + }), + ) + .await; + + let error = request.await.unwrap().unwrap_err(); + assert!(error.message.contains("Invalid Gradle JSON-RPC response")); + assert!(error.is_connection()); + assert!(client.is_closed()); + } + + #[tokio::test] + async fn malformed_response_envelope_closes_connection() { + let (client_side, server_side) = duplex(1024); + let (client_read, client_write) = split(client_side); + let (server_read, mut server_write) = split(server_side); + let client = GradleJsonRpcClient::new(client_read, client_write); + + let request = tokio::spawn({ + let client = client.clone(); + async move { + client + .request("gradle/cancelBuild", &CancelBuildRequest::default(), None) + .await + } + }); + let mut reader = AsyncLspReader::new(server_read); + reader.read_message().await.unwrap().unwrap(); + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "result": Value::Null + }), + ) + .await; + + let error = request.await.unwrap().unwrap_err(); + assert!(error.message.contains("missing numeric id")); + assert!(error.is_connection()); + assert!(client.is_closed()); + } + + #[tokio::test] + async fn malformed_stream_payload_closes_connection() { + let (client_side, server_side) = duplex(1024); + let (client_read, client_write) = split(client_side); + let (server_read, mut server_write) = split(server_side); + let client = GradleJsonRpcClient::new(client_read, client_write); + let (stream_id, _stream) = client.register_get_build_stream().await; + + let request = tokio::spawn({ + let client = client.clone(); + async move { + client + .request( + "gradle/getBuild", + &crate::proto::gradle::GetBuildRequest::default(), + Some(stream_id), + ) + .await + } + }); + let mut reader = AsyncLspReader::new(server_read); + reader.read_message().await.unwrap().unwrap(); + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "method": GET_BUILD_REPLY, + "params": { + "streamId": stream_id, + "payload": "not base64" + } + }), + ) + .await; + + let error = request.await.unwrap().unwrap_err(); + assert!(error + .message + .contains("Invalid base64 Gradle getBuild reply")); + assert!(error.is_connection()); + assert!(client.is_closed()); + } + + #[tokio::test] + async fn cancel_request_completes_while_get_build_is_running() { + let (client_side, server_side) = duplex(64 * 1024); + let (client_read, client_write) = split(client_side); + let (server_read, mut server_write) = split(server_side); + let client = GradleJsonRpcClient::new(client_read, client_write); + let (stream_id, mut stream) = client.register_get_build_stream().await; + + let get_build = tokio::spawn({ + let client = client.clone(); + async move { + client + .request( + "gradle/getBuild", + &crate::proto::gradle::GetBuildRequest::default(), + Some(stream_id), + ) + .await + } + }); + + let mut reader = AsyncLspReader::new(server_read); + let get_build_raw = reader.read_message().await.unwrap().unwrap(); + let get_build_request = parse_lsp_content(&get_build_raw).unwrap(); + + let cancel = tokio::spawn({ + let client = client.clone(); + async move { + client + .request( + "gradle/cancelBuild", + &CancelBuildRequest { + cancellation_key: "sync-1".to_string(), + }, + None, + ) + .await + } + }); + let cancel_raw = reader.read_message().await.unwrap().unwrap(); + let cancel_request = parse_lsp_content(&cancel_raw).unwrap(); + + let cancel_reply = CancelBuildReply { + message: "cancel requested".to_string(), + build_running: true, + }; + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "id": cancel_request["id"], + "result": { "reply": BASE64.encode(cancel_reply.encode_to_vec()) } + }), + ) + .await; + + let cancelled = GetBuildReply { + kind: Some(crate::proto::gradle::get_build_reply::Kind::Cancelled( + Default::default(), + )), + }; + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "method": GET_BUILD_REPLY, + "params": { + "streamId": stream_id, + "payload": BASE64.encode(cancelled.encode_to_vec()) + } + }), + ) + .await; + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "id": get_build_request["id"], + "result": { "reply": Value::Null } + }), + ) + .await; + + let cancel_bytes = cancel.await.unwrap().unwrap().unwrap(); + let decoded_cancel = CancelBuildReply::decode(cancel_bytes.as_slice()).unwrap(); + assert!(decoded_cancel.build_running); + assert!(get_build.await.unwrap().unwrap().is_none()); + + let payload = stream.recv().await.unwrap(); + let decoded = GetBuildReply::decode(payload.as_slice()).unwrap(); + assert!(matches!( + decoded.kind, + Some(crate::proto::gradle::get_build_reply::Kind::Cancelled(_)) + )); + } +} diff --git a/gradle-bridge/src/main.rs b/gradle-bridge/src/main.rs index 807d455..7c150fa 100644 --- a/gradle-bridge/src/main.rs +++ b/gradle-bridge/src/main.rs @@ -1,74 +1,65 @@ //! `gradle-lsp-bridge` — bridges Zed (LSP over stdio) to the Microsoft Gradle //! Language Server (LSP over a Unix socket / Windows named pipe) and drives the -//! real shipped `gradle-server.jar` over gRPC to feed the LS a plugin-aware +//! real shipped `gradle-server.jar` over JSON-RPC to feed the LS a plugin-aware //! build model. //! //! Invocation (set up by the Zed Java extension): //! //! ```text -//! gradle-lsp-bridge -cp com.microsoft.gradle.GradleLanguageServer +//! gradle-lsp-bridge -cp //! ``` //! -//! The classpath already contains every jar the gradle-server needs -//! (`gradle-server.jar`, grpc-netty, netty, the Tooling API), so the bridge -//! launches the server from the same classpath — no extra jars shipped. +//! The classpath already contains every jar the Gradle server needs, so the +//! bridge launches the task and language servers together in one JVM. mod channel; -mod grpc; +mod jsonrpc; +mod model; mod proto; +mod server; mod sync; mod transport; use std::process; +use std::process::Stdio; use std::sync::Arc; +use std::time::Duration; use channel::EditorChannel; -use grpc::{DistributionConfig, GradleServer}; +use server::{bind_task_pipe, DistributionConfig, GradleServer}; use sync::SyncScheduler; use transport::{pump_editor_to_ls, pump_ls_to_editor, LsWriter}; -/// Parsed launch arguments: the java binary, the LS classpath, and the LS main -/// class. Mirrors the ` -cp ` shape. +const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Parsed launch arguments: the Java binary and Gradle extension classpath. struct Args { java: String, classpath: String, - main_class: String, } fn parse_args() -> Args { let args: Vec = std::env::args().skip(1).collect(); - // Expect: -cp + // Expect: -cp let cp_idx = args.iter().position(|a| a == "-cp"); let (Some(java), Some(cp_idx)) = (args.first().cloned(), cp_idx) else { - eprintln!( - "Usage: gradle-lsp-bridge -cp com.microsoft.gradle.GradleLanguageServer" - ); + eprintln!("Usage: gradle-lsp-bridge -cp "); process::exit(1); }; let Some(classpath) = args.get(cp_idx + 1).cloned() else { eprintln!("gradle-lsp-bridge: missing classpath after -cp"); process::exit(1); }; - let Some(main_class) = args.get(cp_idx + 2).cloned() else { - eprintln!("gradle-lsp-bridge: missing language server main class"); - process::exit(1); - }; - Args { - java, - classpath, - main_class, - } + Args { java, classpath } } -/// The project root the editor opened. Zed launches the bridge with the project -/// root as the working directory (`PWD`), matching how the previous helper -/// resolved it. +/// The project root the editor opened. The process working directory is +/// authoritative; `PWD` is only a fallback when it cannot be read. fn project_dir() -> Option { - std::env::var("PWD").ok().or_else(|| { - std::env::current_dir() - .ok() - .and_then(|p| p.to_str().map(str::to_string)) - }) + std::env::current_dir() + .ok() + .and_then(|path| path.to_str().map(str::to_string)) + .or_else(|| std::env::var("PWD").ok()) } fn main() { @@ -80,18 +71,10 @@ fn main() { eprintln!("gradle-lsp-bridge: failed to start tokio runtime: {e}"); process::exit(1); }); - runtime.block_on(run(args)); -} - -/// Construct the long-lived gradle-server manager from the launch args + env. -fn build_server(args: &Args) -> GradleServer { - let java_home = std::env::var("JAVA_HOME").ok().filter(|s| !s.is_empty()); - GradleServer::new( - args.java.clone(), - args.classpath.clone(), - java_home, - DistributionConfig::from_env(), - ) + if let Err(error) = runtime.block_on(run(args)) { + eprintln!("gradle-lsp-bridge: {error}"); + process::exit(1); + } } /// Wire up the channel/writer/scheduler and run both pumps to completion. @@ -112,120 +95,167 @@ where let editor = tokio::io::stdin(); let editor_to_ls = tokio::spawn(pump_editor_to_ls(editor, ls_writer, scheduler)); - // Either side closing ends the bridge. - tokio::select! { - _ = ls_to_editor => {} - _ = editor_to_ls => {} + // Either side closing ends the bridge. Abort and await the other pump so no + // detached task retains a pipe or stdout handle during shutdown. + let mut ls_to_editor = ls_to_editor; + let mut editor_to_ls = editor_to_ls; + let ls_finished_first = tokio::select! { + _ = &mut ls_to_editor => { + editor_to_ls.abort(); + true + }, + _ = &mut editor_to_ls => { + ls_to_editor.abort(); + false + }, + }; + if ls_finished_first { + let _ = editor_to_ls.await; + } else { + let _ = ls_to_editor.await; } } #[cfg(unix)] -async fn run(args: Args) { +async fn run(args: Args) -> Result<(), String> { use tokio::net::UnixListener; - let socket_dir = std::env::temp_dir().join(format!("gradle-ls-{}", process::id())); - if let Err(e) = tokio::fs::create_dir_all(&socket_dir).await { - eprintln!("gradle-lsp-bridge: failed to create socket dir: {e}"); - process::exit(1); - } - let socket_path = socket_dir.join("ls.sock"); - - let listener = match UnixListener::bind(&socket_path) { - Ok(l) => l, - Err(e) => { - eprintln!("gradle-lsp-bridge: failed to bind socket: {e}"); - process::exit(1); - } - }; + let socket_dir = server::socket_directory()?; + let _socket_cleanup = UnixSocketCleanup(socket_dir.clone()); + let language_path = socket_dir.join("ls.sock"); + let task_path = socket_dir.join("task.sock"); + let language_path = language_path + .to_str() + .ok_or_else(|| "Gradle language socket path is not UTF-8".to_string())?; + let task_path = task_path + .to_str() + .ok_or_else(|| "Gradle task socket path is not UTF-8".to_string())?; - // Spawn the language server pointed at our socket. - let mut ls_child = match std::process::Command::new(&args.java) - .args([ - "-cp", - &args.classpath, - &args.main_class, - &socket_path.to_string_lossy(), - ]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .spawn() - { - Ok(c) => c, - Err(e) => { - eprintln!("gradle-lsp-bridge: failed to spawn language server: {e}"); - process::exit(1); - } - }; + let language_listener = UnixListener::bind(language_path) + .map_err(|error| format!("Failed to bind Gradle language socket: {error}"))?; + let (server, task_listener) = bind_task_pipe(task_path, DistributionConfig::from_env())?; + let mut child = spawn_gradle_server(&args, task_listener.pipe_path(), language_path)?; - // Terminate the LS if the editor that launched us goes away. let alive = Arc::new(std::sync::atomic::AtomicBool::new(true)); - proxy_common::spawn_parent_monitor(Arc::clone(&alive), ls_child.id()); + if let Some(child_pid) = child.id() { + proxy_common::spawn_parent_monitor(Arc::clone(&alive), child_pid); + } - let stream = match listener.accept().await { - Ok((stream, _)) => stream, - Err(e) => { - eprintln!("gradle-lsp-bridge: failed to accept LS connection: {e}"); - let _ = ls_child.kill(); - process::exit(1); + let stream = tokio::select! { + result = language_listener.accept() => { + result + .map(|(stream, _)| stream) + .map_err(|error| format!("Failed to accept Gradle language connection: {error}"))? + } + status = child.wait() => { + return Err(format!("Gradle server exited before connecting: {}", format_exit(status))); + } + _ = tokio::time::sleep(STARTUP_TIMEOUT) => { + return Err("Timed out waiting for Gradle language server connection".to_string()); } }; let (ls_read, ls_write) = stream.into_split(); - let server = build_server(&args); - drive(ls_read, ls_write, server.clone()).await; + drive(ls_read, ls_write, server).await; - server.shutdown().await; - let _ = ls_child.kill(); - let _ = tokio::fs::remove_file(&socket_path).await; - let _ = tokio::fs::remove_dir(&socket_dir).await; + alive.store(false, std::sync::atomic::Ordering::Release); + stop_child(&mut child).await; + drop(task_listener); + Ok(()) +} + +#[cfg(unix)] +struct UnixSocketCleanup(std::path::PathBuf); + +#[cfg(unix)] +impl Drop for UnixSocketCleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } } #[cfg(windows)] -async fn run(args: Args) { +async fn run(args: Args) -> Result<(), String> { use tokio::net::windows::named_pipe::ServerOptions; - let pipe_name = format!("\\\\.\\pipe\\gradle-ls-{}", process::id()); - let server_pipe = match ServerOptions::new() + let language_pipe = server::windows_pipe_name("language"); + let task_pipe = server::windows_pipe_name("task"); + let language_listener = ServerOptions::new() .first_pipe_instance(true) - .create(&pipe_name) - { - Ok(p) => p, - Err(e) => { - eprintln!("gradle-lsp-bridge: failed to create named pipe: {e}"); - process::exit(1); - } - }; - - // Spawn the language server pointed at our pipe. - let mut ls_child = match std::process::Command::new(&args.java) - .args(["-cp", &args.classpath, &args.main_class, &pipe_name]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .spawn() - { - Ok(c) => c, - Err(e) => { - eprintln!("gradle-lsp-bridge: failed to spawn language server: {e}"); - process::exit(1); - } - }; + .reject_remote_clients(true) + .create(&language_pipe) + .map_err(|error| format!("Failed to create Gradle language pipe: {error}"))?; + let (server, task_listener) = bind_task_pipe(&task_pipe, DistributionConfig::from_env())?; + let mut child = spawn_gradle_server(&args, task_listener.pipe_path(), &language_pipe)?; let alive = Arc::new(std::sync::atomic::AtomicBool::new(true)); - proxy_common::spawn_parent_monitor(Arc::clone(&alive), ls_child.id()); + if let Some(child_pid) = child.id() { + proxy_common::spawn_parent_monitor(Arc::clone(&alive), child_pid); + } - if let Err(e) = server_pipe.connect().await { - eprintln!("gradle-lsp-bridge: failed to accept pipe connection: {e}"); - let _ = ls_child.kill(); - process::exit(1); + tokio::select! { + result = language_listener.connect() => { + result.map_err(|error| format!("Failed to accept Gradle language connection: {error}"))?; + } + status = child.wait() => { + return Err(format!("Gradle server exited before connecting: {}", format_exit(status))); + } + _ = tokio::time::sleep(STARTUP_TIMEOUT) => { + return Err("Timed out waiting for Gradle language server connection".to_string()); + } } - let (ls_read, ls_write) = transport::split_duplex(server_pipe); + let (ls_read, ls_write) = transport::split_duplex(language_listener); - let server = build_server(&args); - drive(ls_read, ls_write, server.clone()).await; + drive(ls_read, ls_write, server).await; + + alive.store(false, std::sync::atomic::Ordering::Release); + stop_child(&mut child).await; + drop(task_listener); + Ok(()) +} + +fn spawn_gradle_server( + args: &Args, + task_pipe: &str, + language_pipe: &str, +) -> Result { + let mut command = tokio::process::Command::new(&args.java); + command + .args([ + "-Dfile.encoding=UTF-8", + "-cp", + &args.classpath, + "com.github.badsyntax.gradle.GradleServer", + &format!("--pipe={task_pipe}"), + &format!("--parentPid={}", process::id()), + "--startBuildServer=false", + &format!("--languageServerPipePath={language_pipe}"), + ]) + .stdin(Stdio::null()) + // Bridge stdout is the editor protocol; child output must never inherit it. + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + + if let Ok(java_home) = std::env::var("JAVA_HOME") { + if !java_home.is_empty() { + command.env("JAVA_HOME", &java_home); + command.env("VSCODE_JAVA_HOME", java_home); + } + } + command + .spawn() + .map_err(|error| format!("Failed to spawn Gradle server: {error}")) +} + +async fn stop_child(child: &mut tokio::process::Child) { + if child.try_wait().ok().flatten().is_none() { + let _ = child.start_kill(); + } + let _ = child.wait().await; +} - server.shutdown().await; - let _ = ls_child.kill(); +fn format_exit(status: std::io::Result) -> String { + status.map_or_else(|error| error.to_string(), |status| status.to_string()) } diff --git a/gradle-bridge/src/model.rs b/gradle-bridge/src/model.rs new file mode 100644 index 0000000..08ca251 --- /dev/null +++ b/gradle-bridge/src/model.rs @@ -0,0 +1,144 @@ +//! Converts the Gradle build model into the commands consumed by the Gradle +//! Language Server. + +use serde_json::{json, Value}; + +use crate::proto::gradle::GradleProject; + +/// Build the ordered list of `workspace/executeCommand` argument tuples to send +/// to the language server for `root` and every subproject, recursively. +pub fn model_to_commands(root: &GradleProject) -> Vec<(&'static str, Value)> { + let mut commands = Vec::new(); + collect_commands(root, &mut commands); + commands +} + +fn collect_commands(project: &GradleProject, out: &mut Vec<(&'static str, Value)>) { + let project_path = normalize_project_path(&project.project_path); + + out.push(("gradle.setPlugins", json!([project_path, project.plugins]))); + + let closures: Vec = project + .plugin_closures + .iter() + .map(|closure| { + let methods: Vec = closure + .methods + .iter() + .map(|method| { + json!({ + "name": method.name, + "parameterTypes": method.parameter_types, + "deprecated": method.deprecated, + }) + }) + .collect(); + let fields: Vec = closure + .fields + .iter() + .map(|field| { + json!({ + "name": field.name, + "deprecated": field.deprecated, + }) + }) + .collect(); + json!({ + "name": closure.name, + "methods": methods, + "fields": fields, + }) + }) + .collect(); + out.push(("gradle.setClosures", json!([project_path, closures]))); + + out.push(( + "gradle.setScriptClasspaths", + json!([project_path, project.script_classpaths]), + )); + + for subproject in &project.projects { + collect_commands(subproject, out); + } +} + +/// Match the key the language server derives from a document URI without +/// resolving symlinks. +fn normalize_project_path(path: &str) -> String { + use std::path::{Component, PathBuf}; + + let mut normalized = PathBuf::new(); + for component in std::path::Path::new(path).components() { + match component { + Component::ParentDir => { + normalized.pop(); + } + Component::CurDir => {} + other => normalized.push(other.as_os_str()), + } + } + normalized.to_string_lossy().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::gradle::{GradleClosureProto, GradleFieldProto, GradleMethodProto}; + + fn sample_project() -> GradleProject { + GradleProject { + is_root: true, + tasks: vec![], + projects: vec![GradleProject { + is_root: false, + project_path: "/p/sub".to_string(), + plugins: vec!["java".to_string()], + ..Default::default() + }], + project_path: "/p".to_string(), + dependency_item: None, + plugins: vec!["java".to_string(), "application".to_string()], + plugin_closures: vec![GradleClosureProto { + name: "java".to_string(), + methods: vec![GradleMethodProto { + name: "sourceCompatibility".to_string(), + parameter_types: vec!["String".to_string()], + deprecated: false, + }], + fields: vec![GradleFieldProto { + name: "sourceSets".to_string(), + deprecated: false, + }], + }], + script_classpaths: vec!["/p/.gradle/x.jar".to_string()], + } + } + + #[test] + fn emits_three_commands_per_project_recursively() { + let commands = model_to_commands(&sample_project()); + assert_eq!(commands.len(), 6); + assert_eq!(commands[0].0, "gradle.setPlugins"); + assert_eq!(commands[1].0, "gradle.setClosures"); + assert_eq!(commands[2].0, "gradle.setScriptClasspaths"); + assert_eq!(commands[0].1[0], "/p"); + assert_eq!(commands[0].1[1][0], "java"); + assert_eq!(commands[3].1[0], "/p/sub"); + } + + #[test] + fn closure_shape_matches_language_server_contract() { + let commands = model_to_commands(&sample_project()); + let closures = &commands[1].1[1]; + assert_eq!(closures[0]["name"], "java"); + assert_eq!(closures[0]["methods"][0]["name"], "sourceCompatibility"); + assert_eq!(closures[0]["methods"][0]["parameterTypes"][0], "String"); + assert_eq!(closures[0]["methods"][0]["deprecated"], false); + assert_eq!(closures[0]["fields"][0]["name"], "sourceSets"); + } + + #[test] + fn normalizes_dot_segments() { + assert_eq!(normalize_project_path("/p/./sub/../sub"), "/p/sub"); + } +} diff --git a/gradle-bridge/src/proto.rs b/gradle-bridge/src/proto.rs index daf3ac4..033e2f9 100644 --- a/gradle-bridge/src/proto.rs +++ b/gradle-bridge/src/proto.rs @@ -1,10 +1,9 @@ -//! Generated gRPC bindings for the shipped `gradle-server` contract. +//! Generated protobuf messages for the shipped `gradle-server` contract. //! -//! The contents of [`gradle`] are produced by `prost`/`tonic` from -//! `proto/gradle.proto` (a verbatim copy of the proto embedded in -//! `gradle-server.jar`) and committed under `src/gen/`, so the build needs no -//! `protoc`. See `proto/gradle.proto` for regeneration instructions. -#[allow(clippy::all, clippy::pedantic, missing_docs)] +//! The contents of [`gradle`] mirror `proto/gradle.proto`, adapted from the +//! schema embedded in `gradle-server.jar`. The committed message types keep +//! protobuf code generation out of normal builds. +#[allow(clippy::all, clippy::pedantic, dead_code, missing_docs)] pub mod gradle { include!("gen/gradle.rs"); } diff --git a/gradle-bridge/src/server.rs b/gradle-bridge/src/server.rs new file mode 100644 index 0000000..b149bd3 --- /dev/null +++ b/gradle-bridge/src/server.rs @@ -0,0 +1,734 @@ +//! Gradle task-server facade and reconnectable pipe listener. + +use std::env; +#[cfg(unix)] +use std::path::Path; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use prost::Message; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::watch; + +use crate::jsonrpc::{GradleJsonRpcClient, RpcError}; +use crate::proto::gradle::{ + get_build_reply::Kind, output::OutputType, CancelBuildReply, CancelBuildRequest, GetBuildReply, + GetBuildRequest, GradleConfig, GradleProject, +}; + +const JAVA_EXTENSION_VERSION: &str = "3.18.0"; +const TASK_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Default)] +pub struct DistributionConfig { + pub gradle_user_home: String, + pub gradle_home: String, + pub version: String, + pub jvm_arguments: String, + pub java_home: String, + pub wrapper_enabled: bool, +} + +impl DistributionConfig { + pub fn from_env() -> Self { + let wrapper_enabled = env::var("GRADLE_SYNC_WRAPPER_ENABLED") + .map(|value| !value.eq_ignore_ascii_case("false")) + .unwrap_or(true); + Self { + gradle_user_home: env_or_empty("GRADLE_SYNC_USER_HOME"), + gradle_home: env_or_empty("GRADLE_SYNC_GRADLE_HOME"), + version: env_or_empty("GRADLE_SYNC_VERSION"), + jvm_arguments: env_or_empty("GRADLE_SYNC_JVM_ARGS"), + java_home: env_or_empty("GRADLE_SYNC_JAVA_HOME"), + wrapper_enabled, + } + } + + fn to_gradle_config(&self) -> GradleConfig { + GradleConfig { + gradle_home: self.gradle_home.clone(), + user_home: self.gradle_user_home.clone(), + jvm_arguments: self.jvm_arguments.clone(), + wrapper_enabled: self.wrapper_enabled, + version: self.version.clone(), + java_extension_version: JAVA_EXTENSION_VERSION.to_string(), + java_home: self.java_home.clone(), + } + } +} + +fn env_or_empty(key: &str) -> String { + env::var(key).unwrap_or_default() +} + +pub enum BuildOutcome { + Model(GradleProject), + Cancelled, + TransportError(String), + Error { error: String, causes: Vec }, +} + +#[derive(Clone)] +struct ConnectedClient { + generation: u64, + client: GradleJsonRpcClient, +} + +/// Cloneable facade used by the sync scheduler. +#[derive(Clone)] +pub struct GradleServer { + clients: watch::Receiver>, + config: DistributionConfig, +} + +impl GradleServer { + pub async fn get_build(&self, project_dir: &str, cancellation_key: &str) -> BuildOutcome { + let client = match self.wait_for_client().await { + Ok(client) => client, + Err(error) => return BuildOutcome::TransportError(error), + }; + let request = GetBuildRequest { + project_dir: project_dir.to_string(), + cancellation_key: cancellation_key.to_string(), + gradle_config: Some(self.config.to_gradle_config()), + show_output_colors: false, + }; + let (stream_id, mut stream) = client.register_get_build_stream().await; + let terminal = client.request("gradle/getBuild", &request, Some(stream_id)); + tokio::pin!(terminal); + let mut state = ReplyState::default(); + let mut protocol_error = None; + let terminal = loop { + tokio::select! { + result = &mut terminal => break Some(result), + payload = stream.recv() => match payload { + Some(payload) => { + if let Err(error) = state.apply_payload(&payload) { + protocol_error = Some(format!( + "Invalid Gradle getBuild protobuf reply: {error}" + )); + break None; + } + } + None => break Some(terminal.await), + }, + } + }; + while protocol_error.is_none() { + let Ok(payload) = stream.try_recv() else { + break; + }; + if let Err(error) = state.apply_payload(&payload) { + protocol_error = Some(format!("Invalid Gradle getBuild protobuf reply: {error}")); + } + } + if protocol_error.is_none() { + if let Some(Ok(Some(payload))) = &terminal { + if let Err(error) = state.apply_payload(payload) { + protocol_error = + Some(format!("Invalid Gradle getBuild protobuf reply: {error}")); + } + } + } + client.remove_get_build_stream(stream_id).await; + if let Some(error) = protocol_error { + client.close(error.clone()).await; + return BuildOutcome::TransportError(error); + } + + match terminal.expect("terminal result is present without a protocol error") { + Ok(Some(_)) => {} + Ok(None) => {} + Err(error) => { + if let Some(compatibility_error) = state.compatibility_error { + return BuildOutcome::Error { + error: compatibility_error, + causes: stderr_causes(&state.stderr), + }; + } + if error.is_connection() { + return BuildOutcome::TransportError(rpc_error_message(&error)); + } + return BuildOutcome::Error { + error: rpc_error_message(&error), + causes: stderr_causes(&state.stderr), + }; + } + } + + if state.cancelled { + return BuildOutcome::Cancelled; + } + if let Some(error) = state.compatibility_error { + return BuildOutcome::Error { + error, + causes: stderr_causes(&state.stderr), + }; + } + match state.model { + Some(model) => BuildOutcome::Model(model), + None => BuildOutcome::Error { + error: "gradle-server returned no build model".to_string(), + causes: stderr_causes(&state.stderr), + }, + } + } + + /// Best-effort cancellation of the currently active build. + pub async fn cancel(&self, cancellation_key: &str) { + let client = match self.wait_for_client().await { + Ok(client) => client, + Err(_) => return, + }; + // A superseding save can race just ahead of getBuild reaching the Java + // handler. Retry a "not running" response briefly so that request is + // cancelled as soon as its cancellation key is registered. + for _ in 0..20 { + let result = client + .request( + "gradle/cancelBuild", + &CancelBuildRequest { + cancellation_key: cancellation_key.to_string(), + }, + None, + ) + .await; + let Ok(Some(payload)) = result else { + return; + }; + let reply = match CancelBuildReply::decode(payload.as_slice()) { + Ok(reply) => reply, + Err(error) => { + client + .close(format!( + "Invalid Gradle cancelBuild protobuf response: {error}" + )) + .await; + return; + } + }; + if reply.build_running { + return; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + async fn wait_for_client(&self) -> Result { + let mut clients = self.clients.clone(); + tokio::time::timeout(TASK_CONNECT_TIMEOUT, async move { + loop { + if let Some(connected) = clients.borrow().clone() { + if !connected.client.is_closed() { + return Ok(connected.client); + } + } + clients + .changed() + .await + .map_err(|_| "Gradle task pipe listener stopped".to_string())?; + } + }) + .await + .map_err(|_| "Timed out waiting for gradle-server task pipe".to_string())? + } +} + +#[derive(Default)] +struct ReplyState { + model: Option, + stderr: String, + compatibility_error: Option, + cancelled: bool, +} + +impl ReplyState { + fn apply_payload(&mut self, payload: &[u8]) -> Result<(), prost::DecodeError> { + let reply = GetBuildReply::decode(payload)?; + match reply.kind { + Some(Kind::GetBuildResult(result)) => { + self.model = result.build.and_then(|build| build.project); + } + Some(Kind::CompatibilityCheckError(error)) => { + self.compatibility_error = Some(error); + } + Some(Kind::Output(output)) if output.output_type == OutputType::Stderr as i32 => { + self.stderr + .push_str(&String::from_utf8_lossy(&output.output_bytes)); + } + Some(Kind::Cancelled(_)) => { + self.cancelled = true; + } + _ => {} + } + Ok(()) + } +} + +fn rpc_error_message(error: &RpcError) -> String { + match (&error.code, &error.data) { + (Some(code), Some(data)) => format!("{} ({code}): {data}", error.message), + (Some(code), None) => format!("{} ({code})", error.message), + (None, _) => error.message.clone(), + } +} + +fn stderr_causes(stderr: &str) -> Vec { + stderr + .trim() + .lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect() +} + +/// Owns the task-pipe accept loop and Unix socket path. +pub struct TaskPipeListener { + pipe_path: String, + accept_task: tokio::task::JoinHandle<()>, + cleanup_path: Option, +} + +impl TaskPipeListener { + pub fn pipe_path(&self) -> &str { + &self.pipe_path + } +} + +impl Drop for TaskPipeListener { + fn drop(&mut self) { + self.accept_task.abort(); + if let Some(path) = &self.cleanup_path { + let _ = std::fs::remove_file(path); + } + } +} + +pub fn bind_task_pipe( + pipe_path: &str, + config: DistributionConfig, +) -> Result<(GradleServer, TaskPipeListener), String> { + let (clients_tx, clients_rx) = watch::channel(None); + let generation = AtomicU64::new(1); + + #[cfg(unix)] + let (accept_task, cleanup_path) = { + let path = PathBuf::from(pipe_path); + if path.exists() { + std::fs::remove_file(&path) + .map_err(|error| format!("Failed to remove stale task socket: {error}"))?; + } + let listener = tokio::net::UnixListener::bind(&path) + .map_err(|error| format!("Failed to bind Gradle task socket: {error}"))?; + let task = tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let next_generation = generation.fetch_add(1, Ordering::Relaxed); + publish_connection(stream, next_generation, clients_tx.clone()).await; + } + }); + (task, Some(path)) + }; + + #[cfg(windows)] + let (accept_task, cleanup_path) = { + use tokio::net::windows::named_pipe::{PipeMode, ServerOptions}; + + let mut options = ServerOptions::new(); + options + .first_pipe_instance(true) + .pipe_mode(PipeMode::Byte) + .access_inbound(true) + .access_outbound(true) + .reject_remote_clients(true); + let first = options + .create(pipe_path) + .map_err(|error| format!("Failed to create Gradle task pipe: {error}"))?; + let path = pipe_path.to_string(); + let task = tokio::spawn(async move { + let mut server = first; + let mut first_instance = false; + loop { + if server.connect().await.is_err() { + return; + } + let connected = server; + let mut options = ServerOptions::new(); + options + .first_pipe_instance(first_instance) + .pipe_mode(PipeMode::Byte) + .access_inbound(true) + .access_outbound(true) + .reject_remote_clients(true); + server = match options.create(&path) { + Ok(server) => server, + Err(_) => return, + }; + first_instance = false; + let next_generation = generation.fetch_add(1, Ordering::Relaxed); + publish_connection(connected, next_generation, clients_tx.clone()).await; + } + }); + (task, None) + }; + + Ok(( + GradleServer { + clients: clients_rx, + config, + }, + TaskPipeListener { + pipe_path: pipe_path.to_string(), + accept_task, + cleanup_path, + }, + )) +} + +async fn publish_connection( + stream: S, + generation: u64, + clients: watch::Sender>, +) where + S: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + let (reader, writer) = tokio::io::split(stream); + let client = GradleJsonRpcClient::new(reader, writer); + let mut closed = client.subscribe_closed(); + let connected = ConnectedClient { generation, client }; + if let Some(previous) = clients.send_replace(Some(connected)) { + previous + .client + .close("Gradle task pipe connection replaced") + .await; + } + + tokio::spawn(async move { + while !*closed.borrow() { + if closed.changed().await.is_err() { + break; + } + } + clients.send_if_modified(|current| { + if current + .as_ref() + .is_some_and(|client| client.generation == generation) + { + *current = None; + true + } else { + false + } + }); + }); +} + +/// Generate a short, unique filesystem socket directory. Unix-domain socket +/// path limits are 103 bytes on macOS and 107 on Linux, so fall back to `/tmp` +/// when the platform temp directory is too long. +#[cfg(unix)] +pub fn socket_directory() -> Result { + use std::time::{SystemTime, UNIX_EPOCH}; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("System clock error: {error}"))? + .as_nanos(); + let name = format!("gradle-ls-{}-{nonce:x}", std::process::id()); + let mut directory = std::env::temp_dir().join(&name); + if path_byte_len(&directory.join("task.sock")) > safe_socket_path_limit() { + directory = Path::new("/tmp").join(name); + } + std::fs::create_dir_all(&directory) + .map_err(|error| format!("Failed to create Gradle socket directory: {error}"))?; + Ok(directory) +} + +#[cfg(unix)] +fn path_byte_len(path: &Path) -> usize { + use std::os::unix::ffi::OsStrExt; + path.as_os_str().as_bytes().len() +} + +#[cfg(target_os = "macos")] +const fn safe_socket_path_limit() -> usize { + 103 +} + +#[cfg(all(unix, not(target_os = "macos")))] +const fn safe_socket_path_limit() -> usize { + 107 +} + +#[cfg(windows)] +pub fn windows_pipe_name(kind: &str) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + format!( + r"\\.\pipe\gradle-ls-{}-{nonce:x}-{kind}", + std::process::id() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::STANDARD as BASE64; + use base64::Engine as _; + use proxy_common::{encode_lsp, parse_lsp_content, AsyncLspReader}; + use serde_json::{json, Value}; + use tokio::io::{duplex, split, AsyncWrite, AsyncWriteExt}; + + use crate::proto::gradle::{get_build_reply, output, GetBuildResult, GradleBuild, Output}; + + fn server_with_client(client: GradleJsonRpcClient) -> GradleServer { + let (_, clients) = watch::channel(Some(ConnectedClient { + generation: 1, + client, + })); + GradleServer { + clients, + config: DistributionConfig::default(), + } + } + + async fn send_json(writer: &mut (impl AsyncWrite + Unpin), value: Value) { + writer + .write_all(encode_lsp(&value).as_bytes()) + .await + .unwrap(); + writer.flush().await.unwrap(); + } + + #[test] + fn reply_state_collects_stderr_and_terminal_model() { + let stderr = GetBuildReply { + kind: Some(Kind::Output(Output { + output_type: output::OutputType::Stderr as i32, + output_bytes: b"broken build".to_vec(), + })), + }; + let model = GradleProject { + project_path: "/project".to_string(), + ..Default::default() + }; + let terminal = GetBuildReply { + kind: Some(get_build_reply::Kind::GetBuildResult(GetBuildResult { + message: String::new(), + build: Some(GradleBuild { + project: Some(model), + }), + })), + }; + let mut state = ReplyState::default(); + state.apply_payload(&stderr.encode_to_vec()).unwrap(); + state.apply_payload(&terminal.encode_to_vec()).unwrap(); + assert_eq!(state.stderr, "broken build"); + assert_eq!(state.model.unwrap().project_path, "/project"); + } + + #[test] + fn cancelled_reply_is_distinct_from_missing_model() { + let reply = GetBuildReply { + kind: Some(Kind::Cancelled(Default::default())), + }; + let mut state = ReplyState::default(); + state.apply_payload(&reply.encode_to_vec()).unwrap(); + assert!(state.cancelled); + } + + #[tokio::test] + async fn unavailable_client_is_a_transport_error() { + let (clients_tx, clients) = watch::channel::>(None); + drop(clients_tx); + let server = GradleServer { + clients, + config: DistributionConfig::default(), + }; + + match server.get_build("/project", "sync-1").await { + BuildOutcome::TransportError(error) => { + assert!(error.contains("listener stopped")); + } + _ => panic!("expected transport error"), + } + } + + #[tokio::test] + async fn malformed_protobuf_reply_closes_connection() { + let (client_side, server_side) = duplex(64 * 1024); + let (client_read, client_write) = split(client_side); + let (server_read, mut server_write) = split(server_side); + let client = GradleJsonRpcClient::new(client_read, client_write); + let server = server_with_client(client.clone()); + let build = tokio::spawn(async move { server.get_build("/project", "sync-1").await }); + + let mut reader = AsyncLspReader::new(server_read); + let raw = reader.read_message().await.unwrap().unwrap(); + let request = parse_lsp_content(&raw).unwrap(); + let stream_id = request["params"]["streamId"].as_u64().unwrap(); + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "method": "gradle/getBuild/reply", + "params": { + "streamId": stream_id, + "payload": BASE64.encode([0xff]) + } + }), + ) + .await; + + match build.await.unwrap() { + BuildOutcome::TransportError(error) => { + assert!(error.contains("Invalid Gradle getBuild protobuf reply")); + } + _ => panic!("expected transport error"), + } + assert!(client.is_closed()); + } + + #[tokio::test] + async fn replacing_client_closes_in_flight_request() { + let (clients_tx, clients) = watch::channel(None); + let (first_bridge, first_peer) = duplex(64 * 1024); + let (first_read, _first_write) = split(first_peer); + publish_connection(first_bridge, 1, clients_tx.clone()).await; + let first_client = clients + .borrow() + .as_ref() + .expect("first client should be published") + .client + .clone(); + + let request = tokio::spawn({ + let first_client = first_client.clone(); + async move { + first_client + .request("gradle/cancelBuild", &CancelBuildRequest::default(), None) + .await + } + }); + let mut first_reader = AsyncLspReader::new(first_read); + first_reader.read_message().await.unwrap().unwrap(); + + let (second_bridge, _second_peer) = duplex(64 * 1024); + publish_connection(second_bridge, 2, clients_tx).await; + + let error = request.await.unwrap().unwrap_err(); + assert!(error.message.contains("replaced")); + assert!(first_client.is_closed()); + assert_eq!( + clients.borrow().as_ref().map(|client| client.generation), + Some(2) + ); + let message = tokio::time::timeout(Duration::from_secs(1), first_reader.read_message()) + .await + .expect("replaced peer should observe EOF") + .unwrap(); + assert!(message.is_none()); + } + + #[tokio::test] + async fn cancellation_retries_until_build_is_registered() { + let (client_side, server_side) = duplex(64 * 1024); + let (client_read, client_write) = split(client_side); + let (server_read, mut server_write) = split(server_side); + let server = server_with_client(GradleJsonRpcClient::new(client_read, client_write)); + let cancellation = tokio::spawn(async move { + server.cancel("sync-1").await; + }); + + let mut reader = AsyncLspReader::new(server_read); + for build_running in [false, true] { + let raw = reader.read_message().await.unwrap().unwrap(); + let request = parse_lsp_content(&raw).unwrap(); + assert_eq!(request["method"], "gradle/cancelBuild"); + let reply = CancelBuildReply { + message: String::new(), + build_running, + }; + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { "reply": BASE64.encode(reply.encode_to_vec()) } + }), + ) + .await; + } + + cancellation.await.unwrap(); + } + + #[tokio::test] + async fn compatibility_notification_takes_precedence_over_terminal_error() { + let (client_side, server_side) = duplex(64 * 1024); + let (client_read, client_write) = split(client_side); + let (server_read, mut server_write) = split(server_side); + let server = server_with_client(GradleJsonRpcClient::new(client_read, client_write)); + let build = tokio::spawn(async move { server.get_build("/project", "sync-1").await }); + + let mut reader = AsyncLspReader::new(server_read); + let raw = reader.read_message().await.unwrap().unwrap(); + let request = parse_lsp_content(&raw).unwrap(); + let stream_id = request["params"]["streamId"].as_u64().unwrap(); + let compatibility = GetBuildReply { + kind: Some(Kind::CompatibilityCheckError( + "Gradle and Java are incompatible".to_string(), + )), + }; + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "method": "gradle/getBuild/reply", + "params": { + "streamId": stream_id, + "payload": BASE64.encode(compatibility.encode_to_vec()) + } + }), + ) + .await; + send_json( + &mut server_write, + json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": { "code": -32603, "message": "Internal error" } + }), + ) + .await; + + match build.await.unwrap() { + BuildOutcome::Error { error, .. } => { + assert_eq!(error, "Gradle and Java are incompatible"); + } + _ => panic!("expected compatibility error"), + } + } + + #[cfg(unix)] + #[tokio::test] + async fn task_socket_accepts_reconnections_and_cleans_up() { + let directory = socket_directory().unwrap(); + let path = directory.join("task.sock"); + let path_string = path.to_str().unwrap().to_string(); + let (_server, listener) = + bind_task_pipe(&path_string, DistributionConfig::default()).unwrap(); + + let first = tokio::net::UnixStream::connect(&path).await.unwrap(); + drop(first); + let second = tokio::net::UnixStream::connect(&path).await.unwrap(); + drop(second); + drop(listener); + assert!(!path.exists()); + let _ = std::fs::remove_dir(directory); + } +} diff --git a/gradle-bridge/src/sync.rs b/gradle-bridge/src/sync.rs index 46cf8ac..d1a4cab 100644 --- a/gradle-bridge/src/sync.rs +++ b/gradle-bridge/src/sync.rs @@ -3,7 +3,7 @@ //! mirrors the VS Code extension, which serializes refreshes and coalesces bursts //! (e.g. "save all") rather than launching a Gradle build per event. //! -//! Unlike the previous fork-per-save helper, a sync here is a `GetBuild` RPC to +//! Unlike the previous fork-per-save helper, a sync here is a `GetBuild` request to //! the already-warm `gradle-server`, so a coalesced burst resolves quickly. use std::sync::Arc; @@ -14,9 +14,12 @@ use tokio::sync::Mutex; use proxy_common::encode_lsp; use crate::channel::{build_eval_diagnostics, EditorChannel, INJECTED_ID_PREFIX}; -use crate::grpc::{model_to_commands, BuildOutcome, GradleServer}; +use crate::model::model_to_commands; +use crate::server::{BuildOutcome, GradleServer}; use crate::transport::LsWriter; +const MAX_TRANSPORT_RETRIES: usize = 3; + /// Coordinates single-flight syncs against the shared [`GradleServer`]. #[derive(Clone)] pub struct SyncScheduler { @@ -35,6 +38,7 @@ struct SyncState { /// A monotonically increasing key so a superseding sync can cancel the /// in-flight build. seq: u64, + active_key: Option, } impl SyncScheduler { @@ -60,41 +64,67 @@ impl SyncScheduler { let mut state = self.state.lock().await; if state.running { state.pending = true; - let key = cancellation_key(state.seq); + let key = state.active_key.clone(); drop(state); - // Cancel the in-flight build; the pending rerun picks up the change. - self.server.cancel(&key).await; + if let Some(key) = key { + // Cancel the in-flight build; the pending rerun picks up the change. + let server = self.server.clone(); + tokio::spawn(async move { + server.cancel(&key).await; + }); + } return; } state.running = true; + state.seq += 1; + let seq = state.seq; + state.active_key = Some(cancellation_key(seq)); drop(state); - self.spawn_worker(); + self.spawn_worker(seq); } - fn spawn_worker(&self) { + fn spawn_worker(&self, initial_seq: u64) { let this = self.clone(); tokio::spawn(async move { + let mut seq = initial_seq; + let mut transport_retries = 0; loop { - let seq = { - let mut s = this.state.lock().await; - s.pending = false; - s.seq += 1; - s.seq + let transport_error = this.run_once(seq).await; + let should_retry_transport = if let Some(error) = &transport_error { + transport_retries += 1; + if transport_retries <= MAX_TRANSPORT_RETRIES { + eprintln!( + "[gradle-bridge] task transport interrupted; retrying model sync: {error}" + ); + true + } else { + this.report_error(error, &[]).await; + false + } + } else { + transport_retries = 0; + false }; - this.run_once(seq).await; - let mut s = this.state.lock().await; + if should_retry_transport { + s.pending = true; + } if !s.pending { s.running = false; + s.active_key = None; break; } + s.pending = false; + s.seq += 1; + seq = s.seq; + s.active_key = Some(cancellation_key(seq)); } }); } /// Perform one `GetBuild` + forward cycle. - async fn run_once(&self, seq: u64) { + async fn run_once(&self, seq: u64) -> Option { let key = cancellation_key(seq); match self.server.get_build(&self.project_dir, &key).await { BuildOutcome::Model(root) => { @@ -113,25 +143,31 @@ impl SyncScheduler { }); self.ls_writer.send(encode_lsp(&msg).into_bytes()).await; } + None } BuildOutcome::Error { error, causes } => { - // Log the full detail (the top-level message plus the Gradle - // stderr captured as causes), not just the generic outer message - // — the causes are where the offending build file and line live. - eprintln!("[gradle-bridge] build model sync failed: {error}"); - for cause in &causes { - eprintln!("[gradle-bridge] {cause}"); - } - let build_file = default_build_file(&self.project_dir); - self.channel - .set_sync_diagnostics(build_eval_diagnostics( - &error, - &causes, - build_file.as_deref(), - )) - .await; + self.report_error(&error, &causes).await; + None + } + BuildOutcome::Cancelled => { + // A superseding save requested a rerun. Preserve the current + // diagnostics until that replacement sync completes. + None } + BuildOutcome::TransportError(error) => Some(error), + } + } + + async fn report_error(&self, error: &str, causes: &[String]) { + // The causes carry the build-file location and line details. + eprintln!("[gradle-bridge] build model sync failed: {error}"); + for cause in causes { + eprintln!("[gradle-bridge] {cause}"); } + let build_file = default_build_file(&self.project_dir); + self.channel + .set_sync_diagnostics(build_eval_diagnostics(error, causes, build_file.as_deref())) + .await; } } diff --git a/src/gradle_bridge.rs b/src/gradle_bridge.rs index d8edc3d..357c2e5 100644 --- a/src/gradle_bridge.rs +++ b/src/gradle_bridge.rs @@ -22,7 +22,7 @@ const GITHUB_REPO: &str = "zed-extensions/java"; /// Downloads and locates the `gradle-lsp-bridge` binary — the native process /// that bridges Zed to the Gradle Language Server and drives the real -/// `gradle-server.jar` over gRPC. Mirrors [`crate::proxy::Proxy`], but resolves +/// `gradle-server.jar` over JSON-RPC. Mirrors [`crate::proxy::Proxy`], but resolves /// its own asset name so the two binaries can be downloaded independently from /// the shared release. pub struct GradleBridge; diff --git a/src/gradle_ls.rs b/src/gradle_ls.rs index 0fc48b9..e7503e2 100644 --- a/src/gradle_ls.rs +++ b/src/gradle_ls.rs @@ -1,11 +1,11 @@ use std::{ - fs::{metadata, read_dir}, - path::PathBuf, + fs::metadata, + path::{Path, PathBuf}, }; use zed_extension_api::{ - self as zed, DownloadedFileType, GithubReleaseOptions, LanguageServerId, - LanguageServerInstallationStatus, Worktree, set_language_server_installation_status, + self as zed, DownloadedFileType, LanguageServerId, LanguageServerInstallationStatus, Worktree, + serde_json::Value, set_language_server_installation_status, }; use crate::{ @@ -14,7 +14,7 @@ use crate::{ }; const INSTALL_PATH: &str = "gradle-ls"; -const GITHUB_REPO: &str = "microsoft/vscode-gradle"; +const SUPPORTED_VERSION: &str = "3.18.0"; const VSIX_PUBLISHER: &str = "vscjava"; const VSIX_EXTENSION: &str = "vscode-gradle"; @@ -32,13 +32,7 @@ impl Downloadable for GradleLs { const INSTALL_PATH: &'static str = INSTALL_PATH; fn find_local(&self) -> Option { - let prefix = PathBuf::from(INSTALL_PATH); - read_dir(&prefix) - .ok()? - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| path.is_dir()) - .find(|path| path.join("lib").is_dir()) + find_supported_installation(Path::new(INSTALL_PATH)) } fn loaded(&self) -> bool { @@ -46,17 +40,18 @@ impl Downloadable for GradleLs { } fn fetch_latest_version(&self, _worktree: &Worktree) -> zed::Result { - let release = zed::latest_github_release( - GITHUB_REPO, - GithubReleaseOptions { - require_assets: false, - pre_release: false, - }, - ) - .map_err(|err| { - format!("Failed to fetch latest Gradle LS release from {GITHUB_REPO}: {err}") - })?; - Ok(release.version) + Ok(SUPPORTED_VERSION.to_string()) + } + + fn version_for_download( + &self, + _language_server_id: &LanguageServerId, + _configuration: &Option, + _worktree: &Worktree, + ) -> zed::Result<(String, bool)> { + // The bridge protocol is coupled to vscode-gradle 3.18. Do not allow + // the shared update-check cache to substitute an incompatible version. + Ok((SUPPORTED_VERSION.to_string(), false)) } fn download( @@ -121,3 +116,36 @@ impl Downloadable for GradleLs { Ok(version_dir) } } + +fn find_supported_installation(install_path: &Path) -> Option { + let version_dir = install_path.join(SUPPORTED_VERSION); + version_dir.join("lib").is_dir().then_some(version_dir) +} + +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + + fn temp_install_path() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("gradle-ls-version-test-{nonce:x}")) + } + + #[test] + fn local_installation_only_accepts_supported_version() { + let install_path = temp_install_path(); + std::fs::create_dir_all(install_path.join("3.17.0/lib")).unwrap(); + assert_eq!(find_supported_installation(&install_path), None); + + let supported = install_path.join(SUPPORTED_VERSION); + std::fs::create_dir_all(supported.join("lib")).unwrap(); + assert_eq!(find_supported_installation(&install_path), Some(supported)); + + std::fs::remove_dir_all(install_path).unwrap(); + } +} diff --git a/src/gradle_ls_server.rs b/src/gradle_ls_server.rs index 9f4c4e1..2ce66cd 100644 --- a/src/gradle_ls_server.rs +++ b/src/gradle_ls_server.rs @@ -69,7 +69,7 @@ impl LanguageServer for GradleLsServer { } // Forward Gradle distribution settings to the bridge (read from the - // process environment, threaded into the gRPC GradleConfig the bridge + // process environment, threaded into the GradleConfig the bridge // sends to gradle-server). Mirrors the knobs the VS Code gradle-server // applies to its Tooling API connection. Sourced from the LSP `settings` // block (the single config source); init options are left empty. @@ -80,12 +80,7 @@ impl LanguageServer for GradleLsServer { Ok(zed::Command { command: bridge_path, - args: vec![ - java_path, - "-cp".to_string(), - classpath, - "com.microsoft.gradle.GradleLanguageServer".to_string(), - ], + args: vec![java_path, "-cp".to_string(), classpath], env, }) } @@ -224,7 +219,7 @@ impl LanguageServer for GradleLsServer { /// `gradle-lsp-bridge`. The keys mirror the language server's own settings /// schema (`gradleUserHome`, `gradleVersion`, `gradleWrapperEnabled`, /// `gradleHome`), read from the LSP `settings` block, and are mapped to the -/// `GRADLE_SYNC_*` variables the bridge reads into the gRPC `GradleConfig` it +/// `GRADLE_SYNC_*` variables the bridge reads into the `GradleConfig` it /// sends to `gradle-server`. `gradle_jvm_arguments` (a string) and the resolved /// JDK home are also forwarded if present. fn gradle_config_env( @@ -234,7 +229,7 @@ fn gradle_config_env( let mut env = Vec::new(); // The JDK the bridge should ask gradle-server to build with. Threaded into - // the gRPC GradleConfig's java_home; mirrors VS Code passing VSCODE_JAVA_HOME. + // GradleConfig's java_home; mirrors VS Code passing VSCODE_JAVA_HOME. if let Some(java_home) = java_home && !java_home.is_empty() {