Skip to content

Commit f93dd01

Browse files
RPC: Fix getObject() desync bugs (#6835)
* RPC: Fix getObject() using wrong baseline for receiving diffs getObject() used localObjects as the diff baseline when receiving from the remote peer. But the remote computes diffs against the last synced state (remoteObjects), not the local state. When the local side modifies a tree (e.g., via a local recipe) before calling getObject(), the two baselines diverge, producing a hybrid tree that corrupts remoteObjects and causes subsequent transfers to fail with IndexError/desync. Fix: use remoteObjects (the last synced state) as the baseline, matching what the remote peer uses. Python's get_object_from_java() already did this correctly; Java and TypeScript had the same bug. * RPC: Reset remote object tracking on getObject() failure to prevent state desync When getObject() fails mid-deserialization (e.g., ClassCastException), the sender has already updated its tracking of what the receiver has, but the receiver never applied the change. This causes all subsequent RPC interactions for that object to compute diffs against the wrong baseline, leading to "Expected positions array" and similar desync errors. Fix by removing remoteObjects[id] when receive() throws, so the next interaction sends a full ADD (no delta), re-synchronizing both sides. Also fix Python's handle_visit() to always fetch the tree from Java via get_object_from_java(), matching the JS implementation. The previous code used a stale local_objects cache, which meant Python could operate on an outdated tree version after Java-side modifications or error recovery. * RPC: Fix Python get_object_from_java missing END_OF_OBJECT on batch boundary When serialized data items are an exact multiple of the handler's batchSize, END_OF_OBJECT ends up alone in a separate batch that receiver.receive() never pulls. Drain the pending batch after receive() completes — analogous to Java/JS's explicit q.take(). Add integration test with small batchSize to exercise the fix.
1 parent f03fa74 commit f93dd01

4 files changed

Lines changed: 101 additions & 15 deletions

File tree

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -461,16 +461,27 @@ List<String> getCursorIds(@Nullable Cursor cursor) {
461461

462462
@VisibleForTesting
463463
public <T> T getObject(String id, @Nullable String sourceFileType) {
464-
// Check if we have a cached version of this object
465-
Object localObject = localObjects.get(id);
464+
// Use the last synced state as the baseline for receiving diffs.
465+
// This must match what the remote used as its baseline when computing the diff.
466+
// Using localObjects here would be wrong if Java modified the tree locally
467+
// (e.g., via a Java-side recipe) since the remote doesn't know about those changes.
468+
Object before = remoteObjects.get(id);
466469

467470
RpcReceiveQueue q = new RpcReceiveQueue(
468471
remoteRefs,
469472
() -> send("GetObject", new GetObject(id, sourceFileType), GetObjectResponse.class),
470473
sourceFileType,
471474
log.get()
472475
);
473-
Object remoteObject = q.receive(localObject, null);
476+
Object remoteObject;
477+
try {
478+
remoteObject = q.receive(before, null);
479+
} catch (Exception e) {
480+
// Reset our tracking of the remote state so the next interaction
481+
// forces a full object sync (ADD) instead of a delta (CHANGE).
482+
remoteObjects.remove(id);
483+
throw e;
484+
}
474485
if (q.take().getState() != END_OF_OBJECT) {
475486
throw new IllegalStateException("Expected END_OF_OBJECT");
476487
}

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.junit.jupiter.api.Disabled;
2626
import org.junit.jupiter.api.Test;
2727
import org.openrewrite.*;
28+
import org.openrewrite.marker.Markers;
2829
import org.openrewrite.config.Environment;
2930
import org.openrewrite.config.OptionDescriptor;
3031
import org.openrewrite.config.RecipeDescriptor;
@@ -38,8 +39,10 @@
3839
import java.io.IOException;
3940
import java.io.PipedInputStream;
4041
import java.io.PipedOutputStream;
42+
import java.nio.file.Path;
4143
import java.util.List;
4244
import java.util.Map;
45+
import java.util.UUID;
4346
import java.util.concurrent.CountDownLatch;
4447

4548
import static java.util.Objects.requireNonNull;
@@ -82,6 +85,57 @@ void after() {
8285
server.shutdown();
8386
}
8487

88+
/**
89+
* Verifies that getObject() uses remoteObjects (last synced state) as the
90+
* diff baseline, not localObjects. When the local side modifies a tree
91+
* (e.g., via a local recipe) before calling getObject(), the localObjects
92+
* entry diverges from what the remote used as its baseline. Using
93+
* localObjects would cause NO_CHANGE fields to retain the local
94+
* modification rather than the synced value.
95+
* <p>
96+
* The bug only manifests for object fields (identity-compared via ==)
97+
* where the sender emits NO_CHANGE. Here, the server changes only text
98+
* (a value field), so Markers — an object field preserved by reference
99+
* in withText() — is sent as NO_CHANGE. If the client locally created
100+
* a different Markers object, the wrong baseline would retain it.
101+
*/
102+
@Test
103+
void getObjectUsesRemoteObjectsAsBaseline() {
104+
PlainText original = PlainText.builder()
105+
.sourcePath(Path.of("test.txt"))
106+
.text("Hello")
107+
.build();
108+
109+
String id = original.getId().toString();
110+
String sourceFileType = PlainText.class.getName();
111+
112+
// Server has the tree; client fetches it → both synced
113+
server.localObjects.put(id, original);
114+
PlainText synced = client.getObject(id, sourceFileType);
115+
UUID syncedMarkersId = synced.getMarkers().getId();
116+
117+
// Server modifies text only — Markers reference stays the same
118+
// (original.withText() preserves the Markers by reference via @With),
119+
// so the handler sends NO_CHANGE for the Markers field.
120+
// Using original (not synced) ensures server's before == original.getMarkers()
121+
// and after == original.withText(...).getMarkers() are the same reference.
122+
server.localObjects.put(id, original.withText("Hello World"));
123+
124+
// Client locally modifies Markers (simulating a local recipe step),
125+
// creating a new Markers object with a different ID
126+
Markers localMarkers = synced.getMarkers().withId(Tree.randomId());
127+
client.localObjects.put(id, synced.withMarkers(localMarkers));
128+
129+
// Fetch: server sends Markers=NO_CHANGE, text=CHANGE("Hello World").
130+
// The receiver should apply NO_CHANGE against remoteObjects (synced
131+
// markers), not localObjects (local markers).
132+
PlainText result = client.getObject(id, sourceFileType);
133+
assertThat(result.getText()).isEqualTo("Hello World");
134+
assertThat(result.getMarkers().getId())
135+
.describedAs("Markers should come from remoteObjects baseline, not localObjects")
136+
.isEqualTo(syncedMarkersId);
137+
}
138+
85139
@DocumentExample
86140
@Test
87141
void sendReceiveIdempotence() {

rewrite-javascript/rewrite/src/rpc/rewrite-rpc.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,11 @@ export class RewriteRpc {
124124
}
125125

126126
async getObject<P>(id: string, sourceFileType?: string): Promise<P> {
127-
const localObject = this.localObjects.get(id);
127+
// Use the last synced state as the baseline for receiving diffs.
128+
// This must match what the remote used as its baseline when computing the diff.
129+
// Using localObjects here would be wrong if the local side modified the tree
130+
// (e.g., via a local recipe) since the remote doesn't know about those changes.
131+
const before = this.remoteObjects.get(id);
128132

129133
const q = new RpcReceiveQueue(this.remoteRefs, sourceFileType, () => {
130134
return this.connection.sendRequest(
@@ -133,7 +137,15 @@ export class RewriteRpc {
133137
);
134138
}, this.logger, this.traceGetObject.receive);
135139

136-
const remoteObject = await q.receive<P>(localObject);
140+
let remoteObject: P;
141+
try {
142+
remoteObject = await q.receive<P>(before as P);
143+
} catch (e) {
144+
// Reset our tracking of the remote state so the next interaction
145+
// forces a full object sync (ADD) instead of a delta (CHANGE).
146+
this.remoteObjects.delete(id);
147+
throw e;
148+
}
137149

138150
const eof = (await q.take());
139151
if (eof.state !== RpcObjectState.END_OF_OBJECT) {

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,22 @@ def pull_batch() -> List[Dict[str, Any]]:
194194
before = remote_objects.get(obj_id)
195195

196196
# Receive and deserialize the object (applies diffs to before state)
197-
obj = receiver.receive(before, q)
198-
199-
# Verify we received the complete object (END_OF_OBJECT was in the final batch)
200-
# This matches Java's RewriteRpc.java line 474-475 which explicitly checks for END_OF_OBJECT
201-
if not received_end:
202-
raise RuntimeError(f"Did not receive END_OF_OBJECT marker for object {obj_id}")
197+
try:
198+
obj = receiver.receive(before, q)
199+
200+
# After receive() completes, END_OF_OBJECT may still be pending in a
201+
# separate batch (happens when data items are an exact multiple of the
202+
# handler's batchSize). Drain it — analogous to Java's explicit
203+
# q.take() after receive().
204+
if not received_end:
205+
pull_batch()
206+
if not received_end:
207+
raise RuntimeError(f"Did not receive END_OF_OBJECT marker for object {obj_id}")
208+
except Exception:
209+
# Reset our tracking of the remote state so the next interaction
210+
# forces a full object sync (ADD) instead of a delta (CHANGE).
211+
remote_objects.pop(obj_id, None)
212+
raise
203213

204214
if obj is not None:
205215
# Update our understanding of what Java has
@@ -1036,10 +1046,9 @@ def handle_visit(params: dict) -> dict:
10361046
if p_id:
10371047
_execution_contexts[p_id] = ctx
10381048

1039-
# Get the tree - fetch from Java if we don't have it locally
1040-
tree = local_objects.get(tree_id)
1041-
if tree is None:
1042-
tree = get_object_from_java(tree_id, source_file_type)
1049+
# Always fetch the tree from Java to ensure we have the latest version.
1050+
# Java may have modified the tree (e.g., via a Java-side recipe) since our last sync.
1051+
tree = get_object_from_java(tree_id, source_file_type)
10431052

10441053
if tree is None:
10451054
raise ValueError(f"Tree not found: {tree_id}")

0 commit comments

Comments
 (0)