Skip to content

Commit e2b99d5

Browse files
Mostly fix NetworkTest (#357)
* (busily) wait for client to connect in testLogin * kick clients instead of directly disconnecting them at the end of testLogin; fixes race condition causing testLogin flakiness tests that wait on DummyClientPacketHandler#disconnectFromServerLatch are still flaky: NetworkTest::connectClient can throw BindException * remove unnecessary EnigmaClient::disconnect calls after DummyClientPacketHandler#disconnectFromServerLatch waits replace NetworkTest::testTakenUsername's client1 diconnect call with kickAfterTest repeat each test 100 times (except testUnapprovedMessage which takes 3 sec. per success) warn about over-repeating tests that wait on DummyClientPacketHandler#disconnectFromServerLatch * adjust client kicking in testTakenUserName * busyAwaitConnection of first client in testTakenuserName * comment that testTakenUsername is still flaky on CI :-[ * use CountDownLock instead of busy waiting * require one connection waiter at a time * try waiting for second client connection in testTakenUsername * stop repeating testTakenUsername * javadoc note about changing testLogin
1 parent bfa4089 commit e2b99d5

File tree

5 files changed

+130
-37
lines changed

5 files changed

+130
-37
lines changed

enigma-server/src/main/java/org/quiltmc/enigma/network/EnigmaServer.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ public void start() throws IOException {
8787
thread.start();
8888
}
8989

90-
private void acceptClient() throws IOException {
90+
@VisibleForTesting
91+
Socket acceptClient() throws IOException {
9192
Socket client = this.socket.accept();
9293
this.clients.add(client);
9394
this.unapprovedClients.add(client);
@@ -127,6 +128,8 @@ private void acceptClient() throws IOException {
127128
thread.setName("Server I/O thread #" + (nextIoId++));
128129
thread.setDaemon(true);
129130
thread.start();
131+
132+
return client;
130133
}
131134

132135
public void stop() {

enigma-server/src/test/java/org/quiltmc/enigma/network/DummyClientPacketHandler.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package org.quiltmc.enigma.network;
22

3+
import org.jspecify.annotations.NonNull;
4+
import org.junit.jupiter.api.Assertions;
35
import org.quiltmc.enigma.api.translation.mapping.EntryChange;
46
import org.quiltmc.enigma.api.translation.mapping.EntryMapping;
57
import org.quiltmc.enigma.api.translation.mapping.tree.EntryTree;
@@ -10,6 +12,7 @@
1012

1113
public class DummyClientPacketHandler implements ClientPacketHandler {
1214
TestEnigmaClient client;
15+
@NonNull
1316
CountDownLatch disconnectFromServerLatch = new CountDownLatch(1);
1417

1518
@Override
@@ -21,15 +24,14 @@ public boolean applyChangeFromServer(EntryChange<?> change) {
2124
return true;
2225
}
2326

27+
@SuppressWarnings("deprecation")
2428
@Override
2529
public void disconnectIfConnected(String reason) {
26-
if (this.client != null) {
27-
this.client.disconnect();
28-
}
30+
Assertions.assertNotNull(this.client, "No client!");
31+
this.client.disconnect();
2932

30-
if (this.disconnectFromServerLatch != null) {
31-
this.disconnectFromServerLatch.countDown();
32-
}
33+
Assertions.assertNotNull(this.disconnectFromServerLatch, "No disconnection latch!");
34+
this.disconnectFromServerLatch.countDown();
3335
}
3436

3537
@Override

enigma-server/src/test/java/org/quiltmc/enigma/network/NetworkTest.java

Lines changed: 85 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,55 @@
33
import org.junit.jupiter.api.AfterAll;
44
import org.junit.jupiter.api.Assertions;
55
import org.junit.jupiter.api.BeforeAll;
6+
import org.junit.jupiter.api.RepeatedTest;
67
import org.junit.jupiter.api.Test;
78
import org.quiltmc.enigma.TestUtil;
89
import org.quiltmc.enigma.api.Enigma;
910
import org.quiltmc.enigma.api.EnigmaProject;
1011
import org.quiltmc.enigma.api.ProgressListener;
1112
import org.quiltmc.enigma.api.class_provider.ClasspathClassProvider;
12-
import org.quiltmc.enigma.api.translation.mapping.EntryRemapper;
1313
import org.quiltmc.enigma.network.packet.c2s.LoginC2SPacket;
1414
import org.quiltmc.enigma.network.packet.c2s.MessageC2SPacket;
15+
import org.quiltmc.enigma.network.packet.s2c.KickS2CPacket;
1516
import org.quiltmc.enigma.util.Utils;
1617

1718
import java.io.IOException;
19+
import java.net.BindException;
20+
import java.net.Socket;
1821
import java.nio.file.Path;
22+
import java.util.List;
1923
import java.util.concurrent.CountDownLatch;
2024
import java.util.concurrent.TimeUnit;
2125

26+
/**
27+
* <b>Warning</b>: Using {@link RepeatedTest @RepeatedTest} on tests that wait on
28+
* {@link DummyClientPacketHandler#disconnectFromServerLatch} eventually causes
29+
* {@link BindException}: {@code Address already in use: connect}.
30+
*
31+
* <p> It seems like all outbound sockets get exhausted and have a timeout before they become available.
32+
*
33+
* <p> On my (supersaiyansubtlety's) PC, the first exception comes after ~20,000 combined repetitions within ~2 minutes,
34+
* after which exceptions are frequently thrown until ~2 minutes have passed without any repetitions.
35+
*
36+
* <p> Each {@link DummyClientPacketHandler#disconnectFromServerLatch}-waiting test succeeded with 1,000 repetitions.
37+
*
38+
* <p> Repetitions are set to {@value #DEFAULT_REPETITIONS} to reasonably test flakiness while avoiding the socket cap.
39+
*/
2240
public class NetworkTest {
2341
private static final Path JAR = TestUtil.obfJar("complete");
2442
private static final String PASSWORD = "foobar";
43+
private static final int DEFAULT_REPETITIONS = 100;
44+
2545
private static byte[] checksum;
2646
private static TestEnigmaServer server;
27-
private static EntryRemapper remapper;
2847

2948
@BeforeAll
3049
public static void startServer() throws IOException {
3150
Enigma enigma = Enigma.create();
3251
EnigmaProject project = enigma.openJar(JAR, new ClasspathClassProvider(), ProgressListener.createEmpty());
3352

3453
checksum = Utils.zipSha1(JAR);
35-
remapper = project.getRemapper();
36-
server = new TestEnigmaServer(checksum, PASSWORD.toCharArray(), remapper, 0);
54+
server = new TestEnigmaServer(checksum, PASSWORD.toCharArray(), project.getRemapper(), 0);
3755

3856
server.start();
3957
}
@@ -50,38 +68,63 @@ private static TestEnigmaClient connectClient(ClientPacketHandler handler) throw
5068
return client;
5169
}
5270

53-
@Test
71+
private static void awaitNextConnection() throws InterruptedException {
72+
Assertions.assertTrue(server.awaitNextConnection(3, TimeUnit.SECONDS), "Client did not connect");
73+
}
74+
75+
/**
76+
* <b>Never</b> directly {@linkplain EnigmaClient#disconnect() disconnect} clients after tests; <b>always</b>
77+
* either manually {@link EnigmaServer#kick(Socket, String) kick} them (like this method does) or wait for them to
78+
* be disconnected using {@link DummyClientPacketHandler#disconnectFromServerLatch}.
79+
*
80+
* <p> Directly {@linkplain EnigmaClient#disconnect() disconnecting} a client creates a race condition:
81+
* The {@code "disconnect.disconnected"} {@link EnigmaServer#kick(Socket, String) kick} call in
82+
* {@link EnigmaServer}'s client threads sends a {@link KickS2CPacket} that can be received by
83+
* <em>other</em> test's clients.
84+
*/
85+
static void kickAfterTest(Socket clientSocket) {
86+
server.kick(clientSocket, "test complete");
87+
}
88+
89+
/**
90+
* When making changes to this test, <em>always</em> test with 100,000 repetitions.
91+
*/
92+
@RepeatedTest(DEFAULT_REPETITIONS)
5493
public void testLogin() throws IOException, InterruptedException {
55-
var handler = new DummyClientPacketHandler();
56-
var client = connectClient(handler);
94+
final var handler = new DummyClientPacketHandler();
95+
96+
server.queueConnectionWait();
97+
98+
final TestEnigmaClient client = connectClient(handler);
5799
handler.client = client;
58100

59-
Assertions.assertFalse(server.getClients().isEmpty());
60-
Assertions.assertFalse(server.getUnapprovedClients().isEmpty());
101+
awaitNextConnection();
61102

103+
Assertions.assertNotEquals(0, handler.disconnectFromServerLatch.getCount(), "The client was disconnected by the server");
104+
105+
final Socket clientSocket = server.getClients().get(0);
106+
final CountDownLatch changeConfirmation = server.waitChangeConfirmation(clientSocket, 1);
62107
client.sendPacket(new LoginC2SPacket(checksum, PASSWORD.toCharArray(), "alice"));
63-
var confirmed = server.waitChangeConfirmation(server.getClients().get(0), 1)
64-
.await(3, TimeUnit.SECONDS);
108+
final boolean confirmed = changeConfirmation.await(3, TimeUnit.SECONDS);
65109

66-
Assertions.assertNotEquals(0, handler.disconnectFromServerLatch.getCount(), "The client was disconnected by the server");
67110
Assertions.assertTrue(confirmed, "Timed out waiting for the change confirmation");
68-
client.disconnect();
111+
112+
kickAfterTest(clientSocket);
69113
}
70114

71-
@Test
115+
@RepeatedTest(DEFAULT_REPETITIONS)
72116
public void testInvalidUsername() throws IOException, InterruptedException {
73117
var handler = new DummyClientPacketHandler();
74118
var client = connectClient(handler);
75119
handler.client = client;
76120

77121
client.sendPacket(new LoginC2SPacket(checksum, PASSWORD.toCharArray(), "<span style=\"color: lavender\">eve</span>"));
78-
var disconnected = handler.disconnectFromServerLatch.await(3, TimeUnit.SECONDS);
122+
boolean disconnected = handler.disconnectFromServerLatch.await(3, TimeUnit.SECONDS);
79123

80124
Assertions.assertTrue(disconnected, "Timed out waiting for the server to kick the client");
81-
client.disconnect();
82125
}
83126

84-
@Test
127+
@RepeatedTest(DEFAULT_REPETITIONS)
85128
public void testWrongPassword() throws IOException, InterruptedException {
86129
var handler = new DummyClientPacketHandler();
87130
var client = connectClient(handler);
@@ -91,31 +134,43 @@ public void testWrongPassword() throws IOException, InterruptedException {
91134
var disconnected = handler.disconnectFromServerLatch.await(3, TimeUnit.SECONDS);
92135

93136
Assertions.assertTrue(disconnected, "Timed out waiting for the server to kick the client");
94-
client.disconnect();
95137
}
96138

139+
// FIXME this test is flaky when run from workflows/build.yml
97140
@Test
98141
public void testTakenUsername() throws IOException, InterruptedException {
99-
var packet = new LoginC2SPacket(checksum, PASSWORD.toCharArray(), "alice");
142+
final var packet = new LoginC2SPacket(checksum, PASSWORD.toCharArray(), "alice");
100143

101-
var handler = new DummyClientPacketHandler();
102-
var client = connectClient(handler);
103-
handler.client = client;
104-
client.sendPacket(packet);
144+
final var handler = new DummyClientPacketHandler();
145+
146+
server.queueConnectionWait();
105147

106-
var handler2 = new DummyClientPacketHandler();
107-
var client2 = connectClient(handler2);
148+
final TestEnigmaClient client1 = connectClient(handler);
149+
handler.client = client1;
150+
client1.sendPacket(packet);
151+
152+
awaitNextConnection();
153+
154+
final var handler2 = new DummyClientPacketHandler();
155+
156+
server.queueConnectionWait();
157+
158+
final TestEnigmaClient client2 = connectClient(handler2);
108159
handler2.client = client2;
109160

161+
awaitNextConnection();
162+
110163
client2.sendPacket(packet);
111-
var disconnected = handler2.disconnectFromServerLatch.await(3, TimeUnit.SECONDS);
164+
final boolean disconnected = handler2.disconnectFromServerLatch.await(3, TimeUnit.SECONDS);
112165

113166
Assertions.assertTrue(disconnected, "Timed out waiting for the server to kick the client");
114-
client.disconnect();
115-
client2.disconnect();
167+
168+
for (final Socket clientSocket : List.copyOf(server.getClients())) {
169+
kickAfterTest(clientSocket);
170+
}
116171
}
117172

118-
@Test
173+
@RepeatedTest(DEFAULT_REPETITIONS)
119174
public void testWrongChecksum() throws IOException, InterruptedException {
120175
var handler = new DummyClientPacketHandler();
121176
var client = connectClient(handler);
@@ -126,9 +181,9 @@ public void testWrongChecksum() throws IOException, InterruptedException {
126181
var disconnected = handler.disconnectFromServerLatch.await(3, TimeUnit.SECONDS);
127182

128183
Assertions.assertTrue(disconnected, "Timed out waiting for the server to kick the client");
129-
client.disconnect();
130184
}
131185

186+
// no repeats because successes take at least 3 seconds
132187
@Test
133188
public void testUnapprovedMessage() throws IOException, InterruptedException {
134189
var handler = new DummyClientPacketHandler();

enigma-server/src/test/java/org/quiltmc/enigma/network/TestEnigmaClient.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import org.quiltmc.enigma.network.packet.Packet;
44

5+
import java.net.Socket;
56
import java.util.concurrent.CountDownLatch;
67

78
public class TestEnigmaClient extends EnigmaClient {
@@ -24,4 +25,13 @@ public void sendPacket(Packet<ServerPacketHandler> packet) {
2425
this.packetSentLatch.countDown();
2526
}
2627
}
28+
29+
/**
30+
* Don't call this at the end of a test; use {@link NetworkTest#kickAfterTest(Socket)} instead.
31+
*/
32+
@Override
33+
@Deprecated
34+
public synchronized void disconnect() {
35+
super.disconnect();
36+
}
2737
}

enigma-server/src/test/java/org/quiltmc/enigma/network/TestEnigmaServer.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package org.quiltmc.enigma.network;
22

3+
import org.jspecify.annotations.NonNull;
4+
import org.junit.jupiter.api.Assertions;
35
import org.quiltmc.enigma.api.translation.mapping.EntryRemapper;
46

57
import java.io.IOException;
@@ -9,8 +11,11 @@
911
import java.util.concurrent.ConcurrentHashMap;
1012
import java.util.concurrent.CountDownLatch;
1113
import java.util.concurrent.LinkedBlockingDeque;
14+
import java.util.concurrent.TimeUnit;
1215

1316
public class TestEnigmaServer extends EnigmaServer {
17+
@NonNull
18+
private CountDownLatch connectionLatch = new CountDownLatch(0);
1419
private final Map<Socket, CountDownLatch> changeConfirmationLatches = new ConcurrentHashMap<>();
1520
private final BlockingQueue<Runnable> tasks = new LinkedBlockingDeque<>();
1621

@@ -38,6 +43,24 @@ public void start() throws IOException {
3843
tasksThread.start();
3944
}
4045

46+
@Override
47+
Socket acceptClient() throws IOException {
48+
final Socket socket = super.acceptClient();
49+
50+
this.connectionLatch.countDown();
51+
52+
return socket;
53+
}
54+
55+
public void queueConnectionWait() {
56+
Assertions.assertEquals(0, this.connectionLatch.getCount());
57+
this.connectionLatch = new CountDownLatch(1);
58+
}
59+
60+
public boolean awaitNextConnection(int timeout, TimeUnit unit) throws InterruptedException {
61+
return this.connectionLatch.await(timeout, unit);
62+
}
63+
4164
@Override
4265
protected void runOnThread(Runnable task) {
4366
this.tasks.add(task);

0 commit comments

Comments
 (0)