Skip to content

Commit becae3b

Browse files
authored
RPC: gate BatchVisit by remote getLanguages (#7553)
Without this, every SourceFile in a repository was being shipped to the remote during a batched RPC recipe run, even types the remote has no codec for. The remote then crashed deserializing them, and the resulting exception was attached to the source as a `Markup.Error` marker — falsely reporting the file as `MODIFY` and causing the Python migration recipes to "touch" every yaml/Dockerfile/license/README in the workspace. Three fixes that ship together: * `RecipeRunCycle.editSource` now consults `currentRpc.getLanguages()` before adding a recipe to the batch, mirroring the `isAcceptable` gate the non-batched path already had. * `rewrite/rpc/server.py` validates with `_require_tree(...)` after every `get_object_from_java(...)` in `handle_visit` / `handle_batch_visit`. When the receiver has no codec for a top-level type it falls back to a `{"kind": value_type, ...}` dict; that dict was leaking into the visitor framework and crashing with `AttributeError: 'dict' object has no attribute 'is_acceptable'`. The guard now raises the same canonical "No RPC codec registered" error the ADD path raises, so the failure mode is consistent regardless of which RPC message shape the remote used. * `RewriteRpc`'s `GetLanguages` handler used to advertise only `PlainText`, `Json$Document`, `J$CompilationUnit`, and `JS$CompilationUnit`. Java actually has receivers for every standard format (Yaml, Xml, Hcl, Properties, Toml, Protobuf, Docker) and every language compilation unit (J, JS, K, G, Cs, Py, Go, S). The handler now lists all of them, each gated by `ifOnClasspath` so the advertised set still narrows to whichever rewrite-* modules are actually loaded.
1 parent a731874 commit becae3b

5 files changed

Lines changed: 109 additions & 8 deletions

File tree

rewrite-core/src/main/java/org/openrewrite/rpc/RewriteRpc.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,29 @@ protected Boolean handle(TraceGetObject request) {
176176
jsonRpc.rpc("GetLanguages", new JsonRpcMethod<Void>() {
177177
@Override
178178
protected Object handle(Void noParams) {
179+
// Advertise every SourceFile type Java has a receiver for so that
180+
// remote peers know they can hand any of these off to Java for
181+
// visiting. Each entry is gated by ifOnClasspath so the actual
182+
// set narrows to whichever rewrite-* modules are loaded in this
183+
// process.
179184
return Stream.of(
180185
ifOnClasspath("org.openrewrite.text.PlainText"),
181186
ifOnClasspath("org.openrewrite.json.tree.Json$Document"),
187+
ifOnClasspath("org.openrewrite.yaml.tree.Yaml$Documents"),
188+
ifOnClasspath("org.openrewrite.xml.tree.Xml$Document"),
189+
ifOnClasspath("org.openrewrite.hcl.tree.Hcl$ConfigFile"),
190+
ifOnClasspath("org.openrewrite.properties.tree.Properties$File"),
191+
ifOnClasspath("org.openrewrite.toml.tree.Toml$Document"),
192+
ifOnClasspath("org.openrewrite.protobuf.tree.Proto$Document"),
193+
ifOnClasspath("org.openrewrite.docker.tree.Docker$File"),
182194
ifOnClasspath("org.openrewrite.java.tree.J$CompilationUnit"),
183-
ifOnClasspath("org.openrewrite.javascript.tree.JS$CompilationUnit")
195+
ifOnClasspath("org.openrewrite.javascript.tree.JS$CompilationUnit"),
196+
ifOnClasspath("org.openrewrite.kotlin.tree.K$CompilationUnit"),
197+
ifOnClasspath("org.openrewrite.groovy.tree.G$CompilationUnit"),
198+
ifOnClasspath("org.openrewrite.csharp.tree.Cs$CompilationUnit"),
199+
ifOnClasspath("org.openrewrite.python.tree.Py$CompilationUnit"),
200+
ifOnClasspath("org.openrewrite.golang.tree.Go$CompilationUnit"),
201+
ifOnClasspath("org.openrewrite.scala.tree.S$CompilationUnit")
184202
).filter(Objects::nonNull).toArray(String[]::new);
185203
}
186204

rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -327,14 +327,20 @@ void clear() {
327327
}
328328

329329
if (isInBatch) {
330-
// Batch path: accumulate visitor names instead of executing
331-
RpcRecipe rpcRecipe = (RpcRecipe) recipe;
332-
batch.items.add(new BatchVisit.BatchVisitItem(rpcRecipe.getEditVisitor(), null));
333-
batch.recipeStacks.add(recipeStack);
334-
if (batch.originalBeforeBatch == null) {
335-
batch.originalBeforeBatch = src;
330+
// Batch path: accumulate visitor names instead of executing.
331+
// Only batch if the remote actually has a codec for this source file type;
332+
// otherwise the BatchVisit RPC fails on the remote side and the resulting
333+
// exception gets attached to the source as a Markup$Error, falsely marking
334+
// the file as modified.
335+
if (currentRpc.getLanguages().contains(src.getClass().getName())) {
336+
RpcRecipe rpcRecipe = (RpcRecipe) recipe;
337+
batch.items.add(new BatchVisit.BatchVisitItem(rpcRecipe.getEditVisitor(), null));
338+
batch.recipeStacks.add(recipeStack);
339+
if (batch.originalBeforeBatch == null) {
340+
batch.originalBeforeBatch = src;
341+
}
342+
batch.rpc = currentRpc;
336343
}
337-
batch.rpc = currentRpc;
338344

339345
// If this is the last recipe in the batch, flush now
340346
if (nextRpc != currentRpc) {

rewrite-core/src/test/java/org/openrewrite/rpc/RewriteRpcTest.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,35 @@ void batchVisitExceptionProducesErrorMarker() {
451451
.isPresent();
452452
}
453453

454+
/**
455+
* Source files whose type the remote does not advertise via GetLanguages must
456+
* not be added to a BatchVisit. Without this gate the remote's receiver crashes
457+
* on the unknown type and the resulting exception is attached to the file as a
458+
* Markup.Error marker, falsely reporting the file as modified.
459+
*/
460+
@Test
461+
void batchSkipsSourceFilesNotInRemoteLanguages() {
462+
// given
463+
// Two consecutive same-RPC recipes are required for the batch path to kick in
464+
Recipe r1 = client.prepareRecipe("org.openrewrite.text.ChangeText", Map.of("toText", "step1"));
465+
Recipe r2 = client.prepareRecipe("org.openrewrite.text.ChangeText", Map.of("toText", "step2"));
466+
467+
// Quark is not in the hardcoded GetLanguages list, so the batch path must skip it.
468+
var quark = new org.openrewrite.quark.Quark(UUID.randomUUID(), Path.of("unknown.bin"), Markers.EMPTY, null, null);
469+
470+
var ctx = new InMemoryExecutionContext();
471+
RecipeRun run = new RecipeScheduler().scheduleRun(
472+
new CompositeRecipe(List.of(r1, r2)),
473+
new InMemoryLargeSourceSet(List.of(quark)), ctx, 1, 1);
474+
475+
// then
476+
assertThat(run.getChangeset().getAllResults())
477+
.describedAs("Quark of an unsupported language type should pass through untouched")
478+
.allSatisfy(result -> assertThat(result.getAfter().getMarkers().findFirst(Markup.Error.class))
479+
.describedAs("No Markup.Error should be attached for an unsupported source type")
480+
.isEmpty());
481+
}
482+
454483
@Test
455484
void getCursor() {
456485
var parent = new Cursor(null, Cursor.ROOT_VALUE);

rewrite-python/rewrite/src/rewrite/rpc/server.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,29 @@ def send_request(method: str, params: dict, timeout_seconds: float = 30.0) -> An
137137
return response.get('result')
138138

139139

140+
def _require_tree(tree: Any, source_file_type: Optional[str]) -> Any:
141+
"""Validate that ``tree`` is a real Tree, not a generic dict fallback.
142+
143+
When the receiver encounters a ``value_type`` it has no codec for, it
144+
falls back to returning a ``{'kind': value_type, ...}`` dict (see
145+
``RpcReceiveQueue._new_obj`` / ``_do_change``). That fallback is
146+
appropriate for nested fragments the visitor framework never inspects
147+
directly, but a top-level SourceFile *must* be a Tree — otherwise the
148+
visitor crashes with a confusing ``AttributeError: 'dict' object has
149+
no attribute 'is_acceptable'``. Raise the same "No RPC codec" error
150+
that the ADD path raises so the failure mode is consistent regardless
151+
of which RPC message shape Java used.
152+
"""
153+
from rewrite import Tree
154+
if isinstance(tree, Tree):
155+
return tree
156+
raise RuntimeError(
157+
f"No RPC codec registered on the Python side for '{source_file_type}'. "
158+
"The remote side has a codec and sent property messages that will not be consumed, "
159+
"causing RPC queue desynchronization."
160+
)
161+
162+
140163
def get_object_from_java(obj_id: str, source_file_type: Optional[str] = None) -> Any:
141164
"""Fetch an object from Java and deserialize it using PythonRpcReceiver.
142165
@@ -1199,6 +1222,8 @@ def handle_visit(params: dict) -> dict:
11991222
if tree is None:
12001223
raise ValueError(f"Tree not found: {tree_id}")
12011224

1225+
tree = _require_tree(tree, source_file_type)
1226+
12021227
# Instantiate the visitor
12031228
visitor = _instantiate_visitor(visitor_name, ctx)
12041229

@@ -1272,6 +1297,8 @@ def handle_batch_visit(params: dict) -> dict:
12721297
if tree is None:
12731298
raise ValueError(f"Tree not found: {tree_id}")
12741299

1300+
tree = _require_tree(tree, source_file_type)
1301+
12751302
from rewrite.visitor import Cursor
12761303
from rewrite.markers import SearchResult
12771304
cursor = Cursor(None, Cursor.ROOT_VALUE)

rewrite-python/rewrite/tests/rpc/test_server.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
1+
def test_require_tree_rejects_dict_fallback():
2+
"""A top-level SourceFile must be a Tree. The receiver returns a generic
3+
``{'kind': value_type, ...}`` dict for value_types it has no codec for —
4+
that fallback is fine for nested fragments but lets unknown source files
5+
leak into the visitor framework, where they crash with
6+
``AttributeError: 'dict' object has no attribute 'is_acceptable'``.
7+
Verify the guard converts that into the same "No RPC codec" error the
8+
ADD path raises so the failure mode is consistent."""
9+
import pytest
10+
from rewrite.rpc.server import _require_tree
11+
12+
with pytest.raises(RuntimeError, match="No RPC codec registered"):
13+
_require_tree(
14+
{"kind": "org.openrewrite.docker.tree.Docker$Document"},
15+
"org.openrewrite.docker.tree.Docker$Document",
16+
)
17+
18+
with pytest.raises(RuntimeError, match="No RPC codec registered"):
19+
_require_tree(None, "org.openrewrite.text.PlainText")
20+
21+
122
def test_handle_parse_preserves_empty_text(tmp_path, monkeypatch):
223
import rewrite.rpc.server as server
324

0 commit comments

Comments
 (0)