Skip to content

Commit ec6f1a6

Browse files
Fix encoder/game thread race; add ID fallback in event refs (#10561)
The encoder previously ran on the Netty I/O thread, reading TrackableObject state concurrently with the game thread's set() calls. EnumMap.writeObject would intermittently fail with AIOOBE, and because RemoteClient.write had no failure listener, the dropped setGameView left the client tracker in an inconsistent state. Move encoding onto the calling thread (which is also the only mutator) and hand the resulting ByteBuf to the channel for async write, preserving non-blocking sends. - Drop EventPlayerRef and route PlayerView through IdRef in event mode. PlayerView is stable in the tracker for the lifetime of a game - Convert IdRef and EventCardRef to records. Same semantics, less boilerplate, free toString() for net log output Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c1da2ae commit ec6f1a6

5 files changed

Lines changed: 128 additions & 74 deletions

File tree

forge-gui/src/main/java/forge/gamemodes/net/CObjectOutputStream.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@ protected void writeClassDescriptor(ObjectStreamClass desc) throws IOException {
2424

2525
@Override
2626
protected Object replaceObject(Object obj) throws IOException {
27-
return TrackableSerializer.replace(obj, null);
27+
return TrackableSerializer.replace(obj, null, false);
2828
}
2929
}

forge-gui/src/main/java/forge/gamemodes/net/CompatibleObjectEncoder.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import forge.trackable.Tracker;
66
import forge.util.IHasForgeLog;
77
import io.netty.buffer.ByteBuf;
8+
import io.netty.buffer.ByteBufAllocator;
89
import io.netty.buffer.ByteBufOutputStream;
910
import io.netty.channel.ChannelHandlerContext;
1011
import io.netty.handler.codec.MessageToByteEncoder;
@@ -29,6 +30,23 @@ public void setTracker(Tracker tracker) {
2930

3031
@Override
3132
protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) throws Exception {
33+
encodeInto(msg, out, this.tracker, this.byteTracker);
34+
}
35+
36+
/** Caller passes the returned buffer to writeAndFlush, which takes ownership. */
37+
public ByteBuf encodeToBuf(Serializable msg, ByteBufAllocator alloc) throws Exception {
38+
ByteBuf out = alloc.buffer();
39+
try {
40+
encodeInto(msg, out, this.tracker, this.byteTracker);
41+
} catch (Exception e) {
42+
out.release();
43+
throw e;
44+
}
45+
return out;
46+
}
47+
48+
private static void encodeInto(Serializable msg, ByteBuf out, Tracker tracker,
49+
NetworkByteTracker byteTracker) throws Exception {
3250
int startIdx = out.writerIndex();
3351
ByteBufOutputStream bout = new ByteBufOutputStream(out);
3452
ObjectOutputStream oout = null;
@@ -39,7 +57,7 @@ protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out)
3957
bout.write(LENGTH_PLACEHOLDER);
4058
if (GuiBase.hasPropertyConfig()) {
4159
oout = replace
42-
? new TrackableSerializer.ReplacingOutputStream(new LZ4BlockOutputStream(bout), tracker)
60+
? new TrackableSerializer.ReplacingOutputStream(new LZ4BlockOutputStream(bout), tracker, false)
4361
: new ObjectOutputStream(new LZ4BlockOutputStream(bout));
4462
} else {
4563
oout = new CObjectOutputStream(new LZ4BlockOutputStream(bout), replace);

forge-gui/src/main/java/forge/gamemodes/net/TrackableSerializer.java

Lines changed: 52 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -33,38 +33,14 @@ public final class TrackableSerializer {
3333
static final byte TYPE_CARD_VIEW = 0;
3434
static final byte TYPE_PLAYER_VIEW = 1;
3535

36-
/**
37-
* Lightweight serializable marker that replaces a TrackableObject reference.
38-
*/
39-
static final class IdRef implements Serializable {
40-
private static final long serialVersionUID = 1L;
41-
final byte typeTag;
42-
final int id;
43-
44-
IdRef(byte typeTag, int id) {
45-
this.typeTag = typeTag;
46-
this.id = id;
47-
}
36+
/** Marker for tracker-stable refs (top-level protocol method args, and PlayerView in events). */
37+
record IdRef(byte typeTag, int id) implements Serializable {
38+
@Serial private static final long serialVersionUID = 1L;
4839
}
4940

50-
/**
51-
* Marker for a stale CardView reference — the object holds a previous
52-
* incarnation of a card (same ID, different Java object) that has since
53-
* been replaced in the tracker by a zone-change copy. Carries the
54-
* image key and name so the decoder can construct a detached CardView
55-
* with the correct display data.
56-
*/
57-
static final class StaleCardRef implements Serializable {
58-
private static final long serialVersionUID = 1L;
59-
final int id;
60-
final String imageKey;
61-
final String name;
62-
63-
StaleCardRef(int id, String imageKey, String name) {
64-
this.id = id;
65-
this.imageKey = imageKey;
66-
this.name = name;
67-
}
41+
/** CardView ref inside a wrapped event. {@code preserveSnapshot=true} forces fallback even if the tracker has the id. */
42+
record EventCardRef(int id, String name, String imageKey, boolean preserveSnapshot) implements Serializable {
43+
@Serial private static final long serialVersionUID = 1L;
6844
}
6945

7046
static byte typeTagFor(TrackableObject obj) {
@@ -83,59 +59,66 @@ static TrackableType<?> trackableTypeFor(byte typeTag) {
8359

8460
/**
8561
* Replaces TrackableObject references with {@link IdRef} markers, or
86-
* {@link StaleCardRef} markers for CardViews whose tracker entry has
87-
* been replaced by a zone-change copy. When {@code tracker} is null,
88-
* stale detection is skipped (used by the client encoder, which has
89-
* no game-state awareness).
62+
* {@link EventCardRef} markers for CardViews inside wrapped events
63+
* ({@code eventMode = true}). When the tracker holds a different object
64+
* for the CardView's id (zone-change copy), {@code preserveSnapshot} is
65+
* set so the receiver decodes a detached CardView from the carried name
66+
* and image key. When {@code tracker} is null, the snapshot check is
67+
* skipped (used by the client encoder, which has no game-state awareness).
9068
*/
91-
static Object replace(Object obj, Tracker tracker) {
69+
static Object replace(Object obj, Tracker tracker, boolean eventMode) {
9270
if (obj instanceof TrackableObject trackable) {
9371
byte tag = typeTagFor(trackable);
94-
if (tag >= 0) {
95-
if (tracker != null) {
96-
TrackableType<?> type = trackableTypeFor(tag);
97-
if (type != null) {
98-
Object tracked = tracker.getObj(type, trackable.getId());
99-
if (tracked != trackable && tag == TYPE_CARD_VIEW && tracked != null) {
100-
// Stale reference: previous incarnation of this card
101-
CardView cv = (CardView) trackable;
102-
String imgKey = cv.getCurrentState() != null
103-
? cv.getCurrentState().getImageKey(null) : null;
104-
return new StaleCardRef(cv.getId(), imgKey, cv.getName());
105-
}
72+
if (tag < 0) return obj;
73+
74+
if (!eventMode || tag == TYPE_PLAYER_VIEW) {
75+
return new IdRef(tag, trackable.getId());
76+
}
77+
78+
boolean preserveSnapshot = false;
79+
if (tracker != null) {
80+
TrackableType<?> type = trackableTypeFor(tag);
81+
if (type != null) {
82+
Object tracked = tracker.getObj(type, trackable.getId());
83+
if (tracked != null && tracked != trackable) {
84+
preserveSnapshot = true;
10685
}
10786
}
108-
return new IdRef(tag, trackable.getId());
10987
}
88+
CardView cv = (CardView) trackable;
89+
String imgKey = cv.getCurrentState() != null
90+
? cv.getCurrentState().getImageKey(null) : null;
91+
return new EventCardRef(trackable.getId(), cv.getName(), imgKey, preserveSnapshot);
11092
}
11193
return obj;
11294
}
11395

11496
/**
115-
* Resolves {@link IdRef} and {@link StaleCardRef} markers back to
97+
* Resolves {@link IdRef} and {@link EventCardRef} markers back to
11698
* TrackableObjects from the given Tracker.
11799
*/
118100
static Object resolve(Object obj, Tracker tracker) {
119-
if (obj instanceof StaleCardRef ref) {
120-
// Create a detached CardView with the correct image key.
121-
// Not registered in the tracker — used only for display
122-
// (game log thumbnail) so it won't affect live game state.
123-
CardView detached = new CardView(ref.id, tracker);
124-
if (ref.name != null) {
125-
detached.set(TrackableProperty.Name, ref.name);
126-
detached.getCurrentState().set(TrackableProperty.Name, ref.name);
101+
if (obj instanceof EventCardRef ref) {
102+
if (!ref.preserveSnapshot()) {
103+
CardView fromTracker = tracker.getObj(TrackableTypes.CardViewType, ref.id());
104+
if (fromTracker != null) return fromTracker;
105+
}
106+
CardView detached = new CardView(ref.id(), tracker);
107+
if (ref.name() != null) {
108+
detached.set(TrackableProperty.Name, ref.name());
109+
detached.getCurrentState().set(TrackableProperty.Name, ref.name());
127110
}
128-
if (ref.imageKey != null) {
129-
detached.getCurrentState().set(TrackableProperty.ImageKey, ref.imageKey);
111+
if (ref.imageKey() != null) {
112+
detached.getCurrentState().set(TrackableProperty.ImageKey, ref.imageKey());
130113
}
131114
return detached;
132115
}
133116
if (obj instanceof IdRef ref) {
134-
TrackableType<?> type = trackableTypeFor(ref.typeTag);
117+
TrackableType<?> type = trackableTypeFor(ref.typeTag());
135118
if (type != null) {
136-
Object resolved = tracker.getObj(type, ref.id);
119+
Object resolved = tracker.getObj(type, ref.id());
137120
if (resolved == null) {
138-
netLog.warn("Could not resolve IdRef(tag={}, id={}) from Tracker", ref.typeTag, ref.id);
121+
netLog.warn("Could not resolve IdRef(tag={}, id={}) from Tracker", ref.typeTag(), ref.id());
139122
}
140123
return resolved;
141124
}
@@ -152,7 +135,7 @@ public static int measureSize(Object obj, Tracker tracker) {
152135
try {
153136
ByteArrayOutputStream baos = new ByteArrayOutputStream();
154137
LZ4BlockOutputStream lz4Out = new LZ4BlockOutputStream(baos);
155-
ObjectOutputStream oos = tracker == null ? new ObjectOutputStream(lz4Out) : new ReplacingOutputStream(lz4Out, tracker);
138+
ObjectOutputStream oos = tracker == null ? new ObjectOutputStream(lz4Out) : new ReplacingOutputStream(lz4Out, tracker, false);
156139
oos.writeObject(obj);
157140
oos.close();
158141
return baos.size();
@@ -163,7 +146,7 @@ public static int measureSize(Object obj, Tracker tracker) {
163146

164147
/**
165148
* Serializable wrapper for a GameEvent whose TrackableObject references
166-
* have been replaced with IdRef/StaleCardRef markers. Stored in
149+
* have been replaced with IdRef/EventCardRef markers. Stored in
167150
* DeltaPacket.events so events travel as compact byte arrays rather than
168151
* full object graphs. Unwrapped after delta state is applied, when the
169152
* client tracker is populated.
@@ -183,7 +166,7 @@ public static List<Object> wrapEvents(List<GameEvent> events, Tracker tracker) {
183166
for (GameEvent event : events) {
184167
try {
185168
ByteArrayOutputStream baos = new ByteArrayOutputStream(256);
186-
try (ReplacingOutputStream out = new ReplacingOutputStream(baos, tracker)) {
169+
try (ReplacingOutputStream out = new ReplacingOutputStream(baos, tracker, true)) {
187170
out.writeObject(event);
188171
}
189172
wrapped.add(new WrappedEvent(baos.toByteArray()));
@@ -219,16 +202,18 @@ public static List<GameEvent> unwrapEvents(List<Object> items, Tracker tracker)
219202

220203
static class ReplacingOutputStream extends ObjectOutputStream {
221204
private final Tracker tracker;
205+
private final boolean eventMode;
222206

223-
ReplacingOutputStream(OutputStream out, Tracker tracker) throws IOException {
207+
ReplacingOutputStream(OutputStream out, Tracker tracker, boolean eventMode) throws IOException {
224208
super(out);
225209
this.tracker = tracker;
210+
this.eventMode = eventMode;
226211
enableReplaceObject(true);
227212
}
228213

229214
@Override
230215
protected Object replaceObject(Object obj) {
231-
return replace(obj, tracker);
216+
return replace(obj, tracker, eventMode);
232217
}
233218
}
234219

forge-gui/src/main/java/forge/gamemodes/net/client/FGameClient.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import forge.gui.interfaces.IGuiGame;
1212
import forge.interfaces.ILobbyListener;
1313
import io.netty.bootstrap.Bootstrap;
14+
import io.netty.buffer.ByteBuf;
1415
import io.netty.channel.*;
1516
import io.netty.channel.nio.NioEventLoopGroup;
1617
import io.netty.channel.socket.SocketChannel;
@@ -105,7 +106,19 @@ public void send(final NetEvent event) {
105106
return;
106107
}
107108
netLog.info("Client sent {}", event);
108-
channel.writeAndFlush(event);
109+
final CompatibleObjectEncoder encoder = channel.pipeline().get(CompatibleObjectEncoder.class);
110+
if (encoder == null) {
111+
netLog.error("No encoder in client pipeline for {}", event);
112+
return;
113+
}
114+
final ByteBuf encoded;
115+
try {
116+
encoded = encoder.encodeToBuf(event, channel.alloc());
117+
} catch (Exception e) {
118+
netLog.error(e, "Client encode error for {}", event);
119+
return;
120+
}
121+
channel.writeAndFlush(encoded);
109122
}
110123

111124
/**

forge-gui/src/main/java/forge/gamemodes/net/server/RemoteClient.java

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import forge.gamemodes.net.event.IdentifiableNetEvent;
88
import forge.gamemodes.net.event.NetEvent;
99
import forge.util.IHasForgeLog;
10-
10+
import io.netty.buffer.ByteBuf;
1111
import io.netty.channel.Channel;
1212

1313
import java.util.concurrent.atomic.AtomicInteger;
@@ -57,11 +57,31 @@ public boolean hasValidSlot() {
5757
return index >= 0;
5858
}
5959

60+
/** Encodes synchronously on the caller's thread. Returns null on failure (logged). */
61+
private ByteBuf encodeOnCallingThread(final NetEvent event) {
62+
final Channel ch = channel;
63+
final CompatibleObjectEncoder encoder = ch.pipeline().get(CompatibleObjectEncoder.class);
64+
if (encoder == null) {
65+
netLog.error("No encoder in pipeline for {} (event: {})", username, event);
66+
sendErrors.incrementAndGet();
67+
return null;
68+
}
69+
try {
70+
return encoder.encodeToBuf(event, ch.alloc());
71+
} catch (Exception e) {
72+
sendErrors.incrementAndGet();
73+
netLog.error(e, "Network encode error for {} (event: {})", username, event);
74+
return null;
75+
}
76+
}
77+
6078
@Override
6179
public void send(final NetEvent event) {
6280
final Channel ch = channel;
6381
recordSendIfSaturated(ch);
64-
ch.writeAndFlush(event).addListener(f -> {
82+
final ByteBuf encoded = encodeOnCallingThread(event);
83+
if (encoded == null) return;
84+
ch.writeAndFlush(encoded).addListener(f -> {
6585
if (!f.isSuccess()) {
6686
sendErrors.incrementAndGet();
6787
Throwable c = f.cause();
@@ -79,15 +99,33 @@ public void send(final NetEvent event) {
7999
public void write(final NetEvent event) {
80100
final Channel ch = channel;
81101
recordSendIfSaturated(ch);
82-
ch.write(event);
102+
final ByteBuf encoded = encodeOnCallingThread(event);
103+
if (encoded == null) return;
104+
ch.write(encoded).addListener(f -> {
105+
if (!f.isSuccess()) {
106+
sendErrors.incrementAndGet();
107+
Throwable c = f.cause();
108+
if (c != null) {
109+
netLog.error(c, "Network write error for {} (event: {})", username, event);
110+
} else {
111+
netLog.error("Network write error for {} (event: {}, cause: {})",
112+
username, event, f.isCancelled() ? "cancelled" : "no cause");
113+
}
114+
}
115+
});
83116
}
84117

85118
@Override
86119
public Object sendAndWait(final IdentifiableNetEvent event) {
87120
replies.initialize(event.getId());
88121
final Channel ch = channel;
89122
recordSendIfSaturated(ch);
90-
ch.writeAndFlush(event).addListener(f -> {
123+
final ByteBuf encoded = encodeOnCallingThread(event);
124+
if (encoded == null) {
125+
replies.complete(event.getId(), null);
126+
return replies.get(event.getId());
127+
}
128+
ch.writeAndFlush(encoded).addListener(f -> {
91129
if (!f.isSuccess()) {
92130
sendErrors.incrementAndGet();
93131
Throwable c = f.cause();

0 commit comments

Comments
 (0)