Skip to content

Commit 8f92303

Browse files
authored
fix(dedup): bind the control socket before the app starts (#189)
* fix(dedup): bind the control socket before the app starts The agent bound /tmp/coverage_control.sock on its background thread, and nothing ordered that bind before the app's first answer to a health check. Keploy fires tests as soon as the app answers, so when the bind came late the first tests' START and END reached no one and their coverage was lost: a run reported 153 of 200 tests covered. The END that met the socket just after it came up logged "Ignoring mismatched END command". start() now binds on the calling thread, which is premain for -javaagent, before the app's main runs, and hands the bound socket to the accept thread. A bind failure still leaves the app running without dedup: start() logs it and returns false, and nothing escapes premain. An END with no START for that test publishes nothing, yet was answered ACK, so Keploy took it as delivered and waited for a record that never came. The unix control socket now answers IGNORED. A Keploy that does not know the reply reads any answer as taken, as it did before. * test(dedup): check the control socket is up when main runs, in CI The smoke harness waited for the control socket file before its first command. That wait is the race Keploy loses: it fires tests as soon as the app answers, with no such wait, so the smoke could not see a late bind. START is now main's first act and nothing waits for the socket, so an agent that binds it after premain fails the smoke with the jar that ships. The smoke fails this way on JDK 8, 17 and 21 with the bind put back on the accept thread. The verify job built with -DskipTests, so the module's unit tests ran in no pipeline. It now runs them on each JDK. * fix(dedup): name a test whose END was ignored on the TCP transport On the TCP transport an END with no START for it was answered with a bare ACK and no COV, so the collector could not tell the test had gone unmeasured. Only the app's own log said so, in a pod the collector never reads. The unix socket already answers IGNORED for the same END. The SDK now sends "IGNORED <id>" ahead of the ACK. The ACK stays because it is what ends the collector's wait: a collector that does not know the frame skips it and goes on as before, where IGNORED in place of ACK would have cost it a full ACK timeout on each such test. * test(dedup): check the app runs when the native library cannot load The agent binds its control socket in premain, so junixsocket's native library now loads there, before the app's main. Whatever that throws, premain must not stop the app: start() catches Throwable and runs the app without dedup. Nothing tested that guard; narrowed to IOException, it passed the unit tests and the smoke while a JVM that cannot load the library aborted before main. The smoke now runs an app with junixsocket's override properties pointing at a library that does not exist, and checks that main ran and that the agent said the control socket is unavailable, so the case cannot pass with the library loaded. * test(dedup): assert the IGNORED reply word that Keploy reads Keploy's agent reads an answer starting "IGNORED", and k8s-proxy's collector a frame starting "IGNORED ", as a command the SDK did not take. The rows compared the reply with the SDK's own constant, so a change to the word passed them while both readers would take every ignored END as taken. They now compare it with the literal. Signed-off-by: slayerjain <shubhamkjain@outlook.com>
1 parent e8ec940 commit 8f92303

6 files changed

Lines changed: 272 additions & 17 deletions

File tree

‎.github/workflows/java-agent.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,8 @@ jobs:
3838
- name: Build
3939
run: mvn -B -DskipTests clean verify
4040

41+
- name: Unit tests
42+
run: mvn -B -pl keploy-sdk -am test
43+
4144
- name: Smoke test Java agent
4245
run: ./scripts/smoke-javaagent.sh

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ Keploy Enterprise drives dynamic dedup per testcase.
2525

2626
Coverage is collected at per-testcase granularity, not process granularity.
2727

28+
The agent binds `/tmp/coverage_control.sock` before the application's `main` runs, so the socket is listening before the application can answer a health check. It answers `ACK` to each command it took, and `IGNORED` to an `END` that no `START` for that testcase came before: nothing is published for it, and Enterprise keeps that testcase out of the comparison. On the TCP transport used in Kubernetes the same `END` is answered with `IGNORED <testcase>` and then `ACK`.
29+
2830
## How to Use
2931

3032
### 1. Download the Keploy Java Agent

‎keploy-sdk/src/main/java/io/keploy/dedup/KeployDedupAgent.java‎

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,19 @@ static String encodeBytecodeFrame(String buildTag, String manifestJson, byte[] z
8484
return "MANIFEST " + b64Tag + " " + b64Manifest;
8585
}
8686

87-
private static final String CONTROL_SOCKET_PATH = "/tmp/coverage_control.sock";
87+
// The unix control socket's path. Not final only so tests can bind one of
88+
// their own; the agent never changes it.
89+
static volatile String controlSocketPath = "/tmp/coverage_control.sock";
8890
private static final String DATA_SOCKET_PATH = "/tmp/coverage_data.sock";
8991
private static final String DEFAULT_JACOCO_HOST = "127.0.0.1";
9092
private static final int DEFAULT_JACOCO_PORT = 36320;
9193
private static final int SOCKET_TIMEOUT_MILLIS = 3000;
9294
private static final int SOCKET_BACKLOG = 50;
95+
// The answer to an END not taken (no START for that test came first, as
96+
// after a restart): nothing was published for it, so Keploy must not use
97+
// the test's coverage. On the unix socket it is the whole answer; on TCP
98+
// it is a frame naming the test, and ACK still follows (CoverageTcpClient).
99+
static final String IGNORED_REPLY = "IGNORED";
93100

94101
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
95102
private static final AtomicBoolean SHUTDOWN_HOOK_REGISTERED = new AtomicBoolean(false);
@@ -170,7 +177,24 @@ public static boolean start() {
170177
} else {
171178
// local/docker: SDK owns the unix control socket and pushes coverage
172179
// back over the unix data socket on a pod-shared /tmp.
173-
CommandServer server = new CommandServer(collector, new CoveragePublisher(new File(DATA_SOCKET_PATH)));
180+
//
181+
// Bind here, on the caller's thread, not on the accept thread:
182+
// premain runs before the app's main, so the socket is listening
183+
// before any app code can answer a health check, and Keploy's
184+
// first START finds it. Bound on the accept thread, it could come
185+
// up after the app answered and Keploy had fired its first tests,
186+
// whose START and END then reached no one: their coverage was lost.
187+
CommandServer server = new CommandServer(collector,
188+
new CoveragePublisher(new File(DATA_SOCKET_PATH)), new File(controlSocketPath));
189+
try {
190+
server.bind();
191+
} catch (Throwable t) {
192+
// Never let premain fail the app's start: run it without dedup.
193+
server.close();
194+
STARTED.set(false);
195+
log(Level.SEVERE, "Java dedup control socket server is unavailable", t);
196+
return false;
197+
}
174198
worker = server;
175199
coverageWorker = server;
176200
threadName = "keploy-java-dedup-control";
@@ -332,26 +356,32 @@ private static final class CommandServer implements Runnable, Closeable {
332356

333357
private final CoverageCollector collector;
334358
private final CoveragePublisher publisher;
359+
private final File controlSocket;
335360
private final AtomicBoolean running = new AtomicBoolean(true);
336361
private final Object testCaseLock = new Object();
337362
private volatile AFUNIXServerSocket serverSocket;
338363
private String activeTestId = "";
339364

340-
CommandServer(CoverageCollector collector, CoveragePublisher publisher) {
365+
CommandServer(CoverageCollector collector, CoveragePublisher publisher, File controlSocket) {
341366
this.collector = collector;
342367
this.publisher = publisher;
368+
this.controlSocket = controlSocket;
343369
}
344370

345-
@Override
346-
public void run() {
347-
File controlSocket = new File(CONTROL_SOCKET_PATH);
371+
// bind replaces a stale socket file and listens on the control socket.
372+
// start() calls it before it returns, so a command sent once start()
373+
// has returned waits in the backlog for run() instead of being refused.
374+
// On a failure the caller closes this server, which closes the socket.
375+
void bind() throws IOException {
348376
deleteSocketFile(controlSocket);
377+
serverSocket = AFUNIXServerSocket.newInstance();
378+
serverSocket.bind(AFUNIXSocketAddress.of(controlSocket), SOCKET_BACKLOG);
379+
relaxSocketPermissions(controlSocket);
380+
}
349381

350-
try (AFUNIXServerSocket localServer = AFUNIXServerSocket.newInstance()) {
351-
localServer.bind(AFUNIXSocketAddress.of(controlSocket), SOCKET_BACKLOG);
352-
relaxSocketPermissions(controlSocket);
353-
serverSocket = localServer;
354-
382+
@Override
383+
public void run() {
384+
try (AFUNIXServerSocket localServer = serverSocket) {
355385
while (running.get()) {
356386
try {
357387
handle(localServer.accept());
@@ -407,7 +437,9 @@ private void dispatch(CoverageCommand command, OutputStream outputStream) {
407437
"Ignoring mismatched END command. expected=" + activeTestId + ", actual="
408438
+ command.testId,
409439
null);
410-
writeAck(outputStream);
440+
// Not ACK: this END publishes nothing, and an ACK told
441+
// Keploy it had, so it waited for a record that never came.
442+
writeReply(outputStream, IGNORED_REPLY);
411443
return;
412444
}
413445

@@ -429,11 +461,15 @@ private void dispatch(CoverageCommand command, OutputStream outputStream) {
429461
}
430462

431463
private void writeAck(OutputStream outputStream) {
464+
writeReply(outputStream, "ACK");
465+
}
466+
467+
private void writeReply(OutputStream outputStream, String reply) {
432468
try {
433-
outputStream.write("ACK\n".getBytes(StandardCharsets.UTF_8));
469+
outputStream.write((reply + "\n").getBytes(StandardCharsets.UTF_8));
434470
outputStream.flush();
435471
} catch (IOException e) {
436-
log(Level.FINE, "Java dedup control client disconnected before ACK was delivered", e);
472+
log(Level.FINE, "Java dedup control client disconnected before " + reply + " was delivered", e);
437473
}
438474
}
439475

@@ -459,6 +495,7 @@ public void close() {
459495
* collector -&gt; SDK : "START &lt;id&gt;" | "END &lt;id&gt;"
460496
* SDK -&gt; collector : "ACK" (after START reset)
461497
* "COV &lt;compact-json&gt;" + "ACK" (after END dump)
498+
* "IGNORED &lt;id&gt;" + "ACK" (after END with no START)
462499
* </pre>
463500
* The collector starts listening only when replay begins, so the connect loop
464501
* retries until it is reachable.
@@ -559,6 +596,11 @@ private void dispatch(CoverageCommand command, OutputStream out) throws IOExcept
559596
"Ignoring mismatched END command. expected=" + activeTestId + ", actual="
560597
+ command.testId,
561598
null);
599+
// Say so before the ACK: with no COV the collector has no
600+
// other way to tell this test went unmeasured. ACK still
601+
// follows, as it ends the collector's wait, and a collector
602+
// that does not know this frame skips it.
603+
writeLine(out, IGNORED_REPLY + " " + command.testId);
562604
writeLine(out, "ACK");
563605
return;
564606
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package io.keploy.dedup;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertFalse;
5+
import static org.junit.Assert.assertTrue;
6+
7+
import java.io.BufferedReader;
8+
import java.io.File;
9+
import java.io.IOException;
10+
import java.io.InputStreamReader;
11+
import java.io.OutputStream;
12+
import java.nio.charset.StandardCharsets;
13+
import java.nio.file.Files;
14+
import java.nio.file.attribute.PosixFilePermission;
15+
import org.junit.After;
16+
import org.junit.Before;
17+
import org.junit.Test;
18+
import org.newsclub.net.unix.AFUNIXSocket;
19+
import org.newsclub.net.unix.AFUNIXSocketAddress;
20+
21+
/**
22+
* The unix control socket Keploy sends START and END on. Keploy fires its first
23+
* test as soon as the app answers its health check, so the socket must listen
24+
* by the time start() (premain, before the app's main) returns; and an END the
25+
* SDK does not take must not be answered as taken.
26+
*/
27+
public class ControlSocketTest {
28+
29+
private String savedPath;
30+
private File dir;
31+
32+
@Before
33+
public void setUp() throws IOException {
34+
savedPath = KeployDedupAgent.controlSocketPath;
35+
dir = Files.createTempDirectory("keploy-dedup-control").toFile();
36+
KeployDedupAgent.controlSocketPath = new File(dir, "control.sock").getAbsolutePath();
37+
}
38+
39+
@After
40+
public void tearDown() {
41+
KeployDedupAgent.stop();
42+
KeployDedupAgent.controlSocketPath = savedPath;
43+
File[] left = dir.listFiles();
44+
if (left != null) {
45+
for (File f : left) {
46+
f.delete();
47+
}
48+
}
49+
dir.delete();
50+
}
51+
52+
@Test
53+
public void controlSocketListensWhenStartReturns() throws Exception {
54+
assertTrue(KeployDedupAgent.start());
55+
File socket = new File(KeployDedupAgent.controlSocketPath);
56+
// No wait and no retry: a command Keploy sends the moment start() has
57+
// returned must find the socket listening.
58+
assertTrue("the control socket is bound when start() returns", socket.exists());
59+
try (AFUNIXSocket client = AFUNIXSocket.newInstance()) {
60+
client.connect(AFUNIXSocketAddress.of(socket), 3000);
61+
}
62+
// Keploy's agent may run as another user (sudo, a container).
63+
assertTrue("anyone may write to the control socket",
64+
Files.getPosixFilePermissions(socket.toPath()).contains(PosixFilePermission.OTHERS_WRITE));
65+
}
66+
67+
@Test
68+
public void startReplacesAStaleSocketFile() throws Exception {
69+
File socket = new File(KeployDedupAgent.controlSocketPath);
70+
assertTrue(socket.createNewFile());
71+
assertTrue(KeployDedupAgent.start());
72+
assertEquals("IGNORED", send("END test-set-0/test-1"));
73+
}
74+
75+
@Test
76+
public void startReportsFailureWhenTheControlSocketCannotBind() {
77+
KeployDedupAgent.controlSocketPath = new File(new File(dir, "missing"), "control.sock").getAbsolutePath();
78+
assertFalse("start() with no control socket", KeployDedupAgent.start());
79+
assertFalse(KeployDedupAgent.isStarted());
80+
}
81+
82+
@Test
83+
public void endWithNoStartIsAnsweredIgnoredNotAck() throws Exception {
84+
assertTrue(KeployDedupAgent.start());
85+
assertEquals("IGNORED", send("END test-set-0/test-47"));
86+
}
87+
88+
private static String send(String command) throws IOException {
89+
try (AFUNIXSocket client = AFUNIXSocket.newInstance()) {
90+
client.connect(AFUNIXSocketAddress.of(new File(KeployDedupAgent.controlSocketPath)), 3000);
91+
client.setSoTimeout(30000);
92+
OutputStream out = client.getOutputStream();
93+
out.write((command + "\n").getBytes(StandardCharsets.UTF_8));
94+
out.flush();
95+
BufferedReader in = new BufferedReader(
96+
new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8));
97+
return in.readLine();
98+
}
99+
}
100+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package io.keploy.dedup;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertTrue;
5+
6+
import java.io.BufferedReader;
7+
import java.io.IOException;
8+
import java.io.InputStreamReader;
9+
import java.io.OutputStream;
10+
import java.net.InetAddress;
11+
import java.net.ServerSocket;
12+
import java.net.Socket;
13+
import java.nio.charset.StandardCharsets;
14+
import org.junit.After;
15+
import org.junit.Before;
16+
import org.junit.Test;
17+
18+
/**
19+
* The TCP transport, with the test as the collector the SDK dials (k8s). An END
20+
* the SDK does not take publishes no COV, so it must say which test went
21+
* unmeasured, and must still answer ACK, which is what ends the collector's wait.
22+
*/
23+
public class CoverageTcpClientTest {
24+
25+
private static final String ENDPOINT_PROPERTY = "keploy.coverage.endpoint";
26+
27+
private ServerSocket collector;
28+
29+
@Before
30+
public void setUp() throws IOException {
31+
collector = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
32+
collector.setSoTimeout(30000);
33+
System.setProperty(ENDPOINT_PROPERTY, "127.0.0.1:" + collector.getLocalPort());
34+
}
35+
36+
@After
37+
public void tearDown() throws IOException {
38+
KeployDedupAgent.stop();
39+
System.clearProperty(ENDPOINT_PROPERTY);
40+
collector.close();
41+
}
42+
43+
@Test
44+
public void endWithNoStartNamesTheTestThenAnswersAck() throws Exception {
45+
assertTrue(KeployDedupAgent.start());
46+
try (Socket sdk = collector.accept()) {
47+
sdk.setSoTimeout(30000);
48+
OutputStream out = sdk.getOutputStream();
49+
BufferedReader in = new BufferedReader(
50+
new InputStreamReader(sdk.getInputStream(), StandardCharsets.UTF_8));
51+
52+
// Two, so that a missing ACK shows as the next test's frame in its place.
53+
out.write("END test-set-0/test-47\nEND test-set-0/test-48\n".getBytes(StandardCharsets.UTF_8));
54+
out.flush();
55+
56+
assertEquals("IGNORED test-set-0/test-47", in.readLine());
57+
assertEquals("ACK", in.readLine());
58+
assertEquals("IGNORED test-set-0/test-48", in.readLine());
59+
assertEquals("ACK", in.readLine());
60+
}
61+
}
62+
}

0 commit comments

Comments
 (0)