/* * Copyright 2025 the original author or authors. *

* Licensed under the Moderne Source Available License (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* https://docs.moderne.io/licensing/moderne-source-available-license *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as fs from "node:fs"; import * as path from "node:path"; import * as readline from "node:readline"; import {ChildProcessWithoutNullStreams, spawn} from "node:child_process"; import * as rpc from "vscode-jsonrpc/node"; import {chunkedJsonDecoder} from "./message-decoder"; import {RewriteRpc} from "./rewrite-rpc"; import {RecipeMarketplace} from "../marketplace"; export interface JavaRpcOptions { /** Explicit classpath override. Falls back to env var, then test-classpath.txt. */ classpath?: string; /** JAVA_HOME to use. Falls back to process.env.JAVA_HOME, then `java` on PATH. */ javaHome?: string; /** Optional logger forwarded to the underlying RewriteRpc instance. */ logger?: rpc.Logger; /** Optional path to a marketplace CSV — passed via `--marketplace=` to the Java process. */ marketplaceCsv?: string; /** Enable RPC tracing on the Java side (passed as `--trace`). */ trace?: boolean; /** * Pre-built {@link RecipeMarketplace} for the TS-side {@link RewriteRpc} instance * only. Does NOT configure the Java side — for that, use {@link marketplaceCsv}. */ marketplace?: RecipeMarketplace; } /** * Test-only wrapper around a spawned `org.openrewrite.maven.rpc.JavaRewriteRpc` process, * exposing a {@link RewriteRpc} client wired to its stdio over JSON-RPC. * * Typical use: * ``` * const server = await JavaRpcTestServer.start(); * try { * const recipe = await server.rpc.prepareRecipe("org.openrewrite.text.FindAndReplace", {...}); * const result = await server.rpc.visit(parsed, recipe.editVisitor, ctx); * } finally { * await server.dispose(); * } * ``` */ export class JavaRpcTestServer { private constructor( private readonly child: ChildProcessWithoutNullStreams, readonly rpc: RewriteRpc, ) { } /** Spawn the Java RPC server and connect a {@link RewriteRpc} client to its stdio. */ static async start(opts: JavaRpcOptions = {}): Promise { const classpath = opts.classpath ?? findTestClasspath(); if (!classpath) { throw new Error( "Java RPC test classpath not configured. " + "Run `./gradlew :rewrite-javascript:generateTestClasspath`, " + "or set REWRITE_JAVASCRIPT_CLASSPATH." ); } const javaCmd = resolveJavaCommand(opts.javaHome); const args = ["-cp", classpath, "org.openrewrite.maven.rpc.JavaRewriteRpc"]; if (opts.marketplaceCsv) args.push(`--marketplace=${opts.marketplaceCsv}`); if (opts.trace) args.push("--trace"); const child = spawn(javaCmd, args); // Forward Java stderr line-by-line so stack traces surface in the test output. // `readline` handles partial-line buffering and flushes a non-newline-terminated // final line on `close` (a hand-rolled split would silently drop it). readline.createInterface({input: child.stderr, crlfDelay: Infinity}) .on("line", line => { if (line.length > 0) process.stderr.write(`[Java RPC] ${line}\n`); }); // If the JVM dies before we can talk to it, surface that immediately rather // than letting the first RPC request hang waiting for a response. Capture the // listener so we can detach it after the race resolves — otherwise the normal // exit during dispose() fires it, rejecting a Promise nobody is observing // (UnhandledPromiseRejection under Node 15+). let onEarlyExit!: (code: number | null, signal: NodeJS.Signals | null) => void; const earlyExit = new Promise((_, reject) => { onEarlyExit = (code, signal) => { reject(new Error( `Java RPC server exited before any request was sent ` + `(code=${code}, signal=${signal}). Check the [Java RPC] stderr above.` )); }; child.once("exit", onEarlyExit); }); const connection = rpc.createMessageConnection( // Trees and recipe descriptors cross this connection too, so it needs the same headroom // as the server's: the default decoder caps an inbound message at V8's ~512 MB string limit. new rpc.StreamMessageReader(child.stdout, {contentTypeDecoder: chunkedJsonDecoder}), new rpc.StreamMessageWriter(child.stdin), opts.logger, ); // RewriteRpc's constructor calls connection.listen() — no need to call it ourselves. const rewriteRpc = new RewriteRpc(connection, { marketplace: opts.marketplace, logger: opts.logger, }); // Hand back control once we know either (a) the JVM is up and the connection is // listening, or (b) the JVM died early. A trivial round-trip (GetLanguages) does both. try { await Promise.race([ rewriteRpc.languages(), earlyExit, ]); } catch (e) { child.kill("SIGKILL"); throw e; } finally { child.removeListener("exit", onEarlyExit); } return new JavaRpcTestServer(child, rewriteRpc); } /** * Reset accumulated state on both the Java side and the TS-side * {@link RewriteRpc} caches so the next test starts clean. */ async reset(): Promise { await this.rpc.reset(); } /** * End the JSON-RPC connection and wait for the Java process to exit. Falls back * to SIGKILL after a short grace period to ensure no orphan JVMs are left behind. */ async dispose(): Promise { // Attach the exit listener BEFORE signalling shutdown so we never miss the // event in a TOCTOU race between checking exitCode and registering the // listener. The Promise also resolves immediately if the process is // already gone — see the exitCode shortcut below. const exited = new Promise((resolve) => { this.child.once("exit", resolve); }); if (this.child.exitCode !== null) { return; } // End the connection before killing the process: this triggers an EOF on the // Java side's stdin reader, which lets it shut down cleanly. Killing first // can race with in-flight requests and leak the connection-close handler. try { this.rpc.end(); } catch { /* ignore — already closed */ } const grace = setTimeout(() => { this.child.kill("SIGKILL"); }, 5_000); try { await exited; } finally { clearTimeout(grace); } } } /** * Find the classpath for spawning the Java RPC server. * * Resolution order: * 1. `REWRITE_JAVASCRIPT_CLASSPATH` environment variable. * 2. `test-classpath.txt` written by the `:rewrite-javascript:generateTestClasspath` * Gradle task. Walks up from this source file (works both when running directly * via vitest and when running from `dist/`). * * @returns the classpath string, or `undefined` if no source is configured. */ export function findTestClasspath(): string | undefined { const env = process.env.REWRITE_JAVASCRIPT_CLASSPATH; if (env && env.length > 0) { return env; } // From src/rpc/ (vitest source mode) or dist/rpc/ (compiled), the classpath // sits two levels up at the package root. The cwd fallback handles // consumer projects where this module is in node_modules but the file // lives next to the consumer's package root. const candidates = [ path.resolve(__dirname, "..", "..", "test-classpath.txt"), path.resolve(process.cwd(), "test-classpath.txt"), ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { return fs.readFileSync(candidate, "utf8").trim(); } } return undefined; } function resolveJavaCommand(javaHome?: string): string { const home = javaHome ?? process.env.JAVA_HOME; if (home) { const candidate = path.join(home, "bin", process.platform === "win32" ? "java.exe" : "java"); if (fs.existsSync(candidate)) { return candidate; } } return "java"; }