Skip to content

fix(dedup): answer every command on the control socket, say proto=2 - #190

Merged
slayerjain merged 3 commits into
mainfrom
fix/coverage-control-answer-next
Nov 9, 2026
Merged

slayerjain merged 3 commits into
mainfrom
fix/coverage-control-answer-next

Conversation

@slayerjain

Copy link
Copy Markdown
Member

Related Issue

On macOS, Keploy's agent cannot tell a coverage command the SDK read and took from one it never read. A unix connection closed with its command unread gives the reader a plain close there, not a reset, so the agent counts that command as taken and says nothing. To tell the two apart, every SDK must answer every command it reads, and say that it does.

Describe the changes you've made

Every command gets an answer. Before, the unix control socket answered ACK even when a START's counter reset failed, and even when an END's coverage was never collected or sent. It closed with no answer on a blank line or on a line it could not parse. Keploy read each of those as taken. Now every command the SDK reads gets one line before the close:

Command Answer
START, counters reset ACK proto=2
START whose reset failed (JaCoCo not reachable) IGNORED proto=2, and its END is ignored too
END for the test running, coverage sent ACK proto=2
END whose coverage could not be collected or sent IGNORED proto=2 (the test still ends)
END for another test, or with no START IGNORED proto=2, nothing sent
a blank line, a line with no test id, an unknown command IGNORED proto=2
a command that throws IGNORED proto=2

Only a connection closed with nothing sent gets no answer, as there is no command to answer.

A command that throws no longer stops the server. A RuntimeException in a command (for example a JaCoCo port out of range, which makes the reset throw IllegalArgumentException) left the accept loop. The SDK then refused every later test's command, and dedup was off for the rest of the run. That command is now answered IGNORED proto=2 and the server goes on.

proto=2 tells Keploy that this SDK answers every command it reads. Keploy can then read a close with no answer as a command not taken.

Compatibility

  • Keploy agents read any answer as taken unless it starts with IGNORED, and older ones read every answer as taken. Either way ACK proto=2 reads as ACK did, and IGNORED proto=2 as IGNORED.
  • The answers that change meaning are for commands this SDK already did not take: a START whose reset failed, an END whose coverage was not sent, and a line that is not a command. Agents that know IGNORED now keep those tests out of dedup instead of counting them as covered.
  • On the TCP transport (k8s), a START whose counter reset failed is now answered IGNORED <id> and then ACK, the frame an END not taken already sends, and its END is ignored too. Before, it was answered ACK and its END sent the lines of the tests before it as this test's. Collectors that do not know the frame skip it and still read the ACK.
  • A START whose reset threw (a JaCoCo port out of range) ended the TCP thread, so no later command was answered. It is now ignored and the connection goes on.
  • On TCP, an END whose JaCoCo dump failed sent an empty COV, the frame of a test that ran no line of the app, so the collector could not tell the two apart. It is now answered IGNORED <id> and then ACK, as on the unix socket, and it ends the test.

stop() and start()

  • stop() closed the unix control socket from the caller's thread and returned with the accept thread still running. junixsocket's accept holds the socket's raw fd number and closes that number when it finds the socket file gone. With start() right after stop(), the number could already be the new server's socket, which was closed with its file still there: every later command was refused. The old thread also deleted the socket file by path once it ended, so a start() on the same path (the default path always is) lost its file. And once junixsocket had closed the socket, the accept loop spun a core, logged without end, and isStarted() still said true.
  • Now only the accept thread closes its socket. stop() wakes that accept with a connection of its own and waits up to 5 s for the thread to end, so a command in flight is answered first. If the thread is still running after that (a command still in flight, or the socket file bound again by another process, which then got the wake-up), stop() closes the socket from its own thread, says so at WARNING, and returns. A start() after it waits up to 5 s more for that thread and, if it is still running, refuses at SEVERE rather than bind a second server on the same path.
  • A socket closed under the server (its file deleted, or bound again by another process) is said once at SEVERE and ends the server, and isStarted() says false.
  • junixsocket's delete on close deletes the socket file by path, so stop() deleted the file of a server that had bound the path since. The SDK turns it off and deletes the file once its socket is closed, only while the file is still the one it bound (the same device and inode).

Tested

  • ControlSocketTest, 19 rows. A fake JaCoCo tcpserver (it can drop a connection unanswered, as a JaCoCo that went away does) and a fake data socket run each answer in the table:
    • a test started and ended: both answered ACK proto=2, and its coverage reaches the data socket;
    • an END for another test is ignored, and the test running goes on;
    • a START whose reset failed is ignored, and so are the END of that test and the END of the test before it;
    • an END whose coverage could not be sent is ignored, and ends the test;
    • eight lines that are no command, and bytes that are not UTF-8, are each ignored and reset nothing;
    • a command with no newline before the close is run and answered;
    • a connection closed with nothing sent gets no answer, and the server still answers the next command;
    • a command that throws is ignored, and the server goes on.
  • The rows compare each answer with the literal line, not with the SDK's constant: Keploy reads those words.
  • The smoke test now requires the exact answer ACK proto=2 to its START and END, against the real JaCoCo agent.
  • Seven more rows on stop() and start():
    • stop() returns only once a START in flight is answered and its thread has ended;
    • while stop() waits for a START, the socket file is still there and still listens: only its own thread closes it;
    • start() and stop() 20 times on the same path, every command answered;
    • a socket file deleted while a START runs ends the server, said once at SEVERE, with isStarted() false;
    • stop() after another server bound the path ends the thread, leaves that server's file and socket alone, and says nothing at SEVERE;
    • stop() after the socket file was deleted ends at once, not after its wait;
    • start() while a stopped worker still runs a START refuses, and binds once that worker has ended.
  • CoverageTcpClientTest has three more: a START whose reset failed, with the tests around it; a START that throws; an END whose dump failed, which ends its test.
  • Before the fix, each of 4 runs of 1000 stop()-then-start() rounds on one path (two on JDK 17, two on 21) lost the socket file at least once, and the accept loop went on logging on the closed socket; with it, none of 1000 on JDK 21.
  • A CI run on JDK 21 once had the second command of a row refused ("Connection refused"). That did not come back here on the code before the stop() fix: 60 runs of the module's tests and 40 of ControlSocketTest on JDK 21, and 3000 start-and-stop rounds on fresh paths on JDK 17 and 21. By junixsocket's accept code, it is the same cause: accept closes the fd number it read when it finds the socket file gone, so a stop() that closed the socket from another thread could close the next server's socket.
  • Build, unit tests (28 of 28) and the smoke test on JDK 8, 17 and 21; the module's tests 50 more times on JDK 21, all green.
  • On macOS (26.7, arm64, JDK 17): the module's tests, 28 of 28, then ControlSocketTest and CoverageTcpClientTest 3 more times, all green.
  • Mutants: 37 mutants of the changed conditions, each killed by a failing assertion in a row. They cover main's blank-line check, an answer on a close, the RuntimeException catch and its answer, the answers swapped, proto=2 dropped from either answer, a line that is no command taken, a failed reset unread or kept, a failed START keeping the test before it, the END match, a mismatched or unsent END taken, the test not ended; stop() closing at once, not waiting, or leaving the socket open after its wait; close() closing from outside, or leaving an accept no wake-up reaches; start() binding while the old worker runs; an accept closed by stop() taken for a lost socket file; a socket closed under the server looping on or still started; the socket file deleted whoever's it is, never deleted, or by junixsocket's delete on close; and on TCP, the test set before its reset or kept after a failed one, a failed START or dump naming no test, a throwing START ending the connection, the reset unread, a failed dump sending an empty COV or not ending its test.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added tests that prove my fix is effective
  • New and existing unit tests pass locally with my changes

The unix control socket answered ACK even when a START's counter reset
failed or an END's coverage was never sent, and closed with no answer
on a blank line or a command it could not parse. Keploy read each of
those as taken. A command that threw (a JaCoCo port out of range)
ended the accept loop, so every later test's command was refused.

Every command the SDK reads now gets one line before the close:
"ACK proto=2" when it was taken, "IGNORED proto=2" when not. A START
whose reset failed is ignored and so is its END; an END whose coverage
could not be collected or sent is ignored and still ends the test. A
command that throws is ignored and the server goes on. Only a
connection closed with nothing sent gets no answer.

proto=2 says this SDK answers every command it reads, so Keploy can
read a close with no answer as a command not taken: on macOS a
connection closed with its command unread gives the reader a plain
close, not an error. Agents that read only an IGNORED prefix, or no
answer at all, read "ACK proto=2" as ACK. The TCP transport is
unchanged.

The data socket path is a field, as the control socket's is, so the
rows can bind their own. The smoke test now requires the exact answer.

Signed-off-by: slayerjain <shubhamkjain@outlook.com>
stop() closed the unix control socket from the caller's thread and
returned at once, with the accept thread still running. junixsocket's
accept holds the socket's raw fd number, and when it finds the socket
file gone it closes that number. With start() right after stop(), that
number could already be the new server's socket, which was then closed
with its file still there: every later command was refused. The old
thread also deleted the socket file by path once it ended, so a start()
on the same path, as the default /tmp/coverage_control.sock always is,
lost its file, and every later command found no socket. Once junixsocket
had closed the socket under it, the accept loop went on: it spun a core,
logged without end, and isStarted() still said true.

Now only the accept thread closes its socket. stop() wakes that accept
with a connection of its own and waits up to 5 s for the thread to end,
so a command in flight is answered first; a start() after it waits for
that thread too. A socket closed under the server (its file deleted, or
bound again by another process) is said once and ends the server.

On the TCP transport, a START whose counter reset failed was answered
ACK, and its END sent the lines of the tests before it as this test's.
It is now answered "IGNORED <id>" and then ACK, as an END not taken is,
and its END is ignored too. A START whose reset threw (a JaCoCo port out
of range) ended the TCP thread, so no later command was answered; it is
now ignored and the connection goes on.

Signed-off-by: slayerjain <shubhamkjain@outlook.com>
junixsocket's delete on close deletes the socket file by path. When
another server had bound the control socket's path since (the file
deleted and bound again), stop() deleted that server's file. The SDK
now turns it off and deletes the file once its socket is closed, only
while the file is still the one it bound (the same device and inode).

On the TCP transport, an END whose JaCoCo dump failed sent an empty
COV, the frame of a test that ran no line of the app, so the collector
could not tell the two apart. It is now answered "IGNORED <id>" and
then ACK, as on the unix socket, and it ends the test.

stop() waits for the worker's thread through one interface for both
transports. When its wait is over with the thread still running, it
closes the socket, says so at WARNING and returns, where it waited a
second time before: a start() waits for that thread in turn. bind()
closes the socket it opened when it fails, so close() is only ever
called on a listening socket: it wakes the accept, and closes the
socket from the caller's thread only when the wake-up cannot connect.
The tests shorten the waits.

New rows: stop() during a command leaves the socket open to its own
thread; stop() after another server took the path ends the thread and
leaves that server's file; stop() after the socket file was deleted
ends at once; start() while the stopped worker still runs a command
refuses, and binds once that worker has ended; a TCP END whose dump
failed. The deleted-file row now also checks that it is said once.

Signed-off-by: slayerjain <shubhamkjain@outlook.com>
@slayerjain
slayerjain merged commit d7d3db8 into main Nov 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant