Skip to content

Feat/dynamic dedup tcp#184

Open
praagyajain wants to merge 8 commits into
mainfrom
feat/dynamic-dedup-tcp
Open

Feat/dynamic dedup tcp#184
praagyajain wants to merge 8 commits into
mainfrom
feat/dynamic-dedup-tcp

Conversation

@praagyajain

Copy link
Copy Markdown

Related Issue

  • Info about Issue or bug

Closes: #[issue number that will be closed through this PR]

Describe the changes you've made

A clear and concise description of what you have done to successfully close your assigned issue. Any new files? or anything you feel to let us know!

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Code style update (formatting, local variables)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How did you test your code changes?

Please describe the tests(if any). Provide instructions how its affecting the coverage.

Describe if there is any unusual behaviour of your code(Write NA if there isn't)

A clear and concise description of it.

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 and used java doc.
  • I have made corresponding changes to the documentation.
  • My changes generate no new warnings.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.

Screenshots (if any)

Original Updated
original screenshot updated screenshot

praagyajain and others added 3 commits June 14, 2026 14:49
In k8s the dedup coverage collector runs in the k8s-proxy Deployment pod while
the app JVM is in a separate pod, so the shared-/tmp unix sockets can't reach it.
Add a TCP transport selected by KEPLOY_COVERAGE_ENDPOINT (host:port): the SDK
dials the collector and keeps one bidirectional connection for the whole replay.

Wire protocol (newline-delimited), matching the k8s-proxy collector:
  collector -> SDK : "START <id>" | "END <id>"
  SDK -> collector : "ACK"                        (after START reset)
                     "COV <compact-json>" + "ACK" (after END dump)

- CoverageTcpClient mirrors CommandServer's dispatch but inverts roles (SDK is the
  client); reuses CoverageCollector reset/capture unchanged. COV precedes ACK so the
  collector records the payload before the ACK releases the caller.
- Connect loop retries (collector listens only once replay begins). No read timeout
  on the long-lived idle-between-tests connection.
- Unix transport stays the default for local/docker (unchanged); the worker field is
  generalized to Closeable so stop() is transport-agnostic.

Validated end-to-end: real JVM (this SDK as -javaagent, TCP client) against the
k8s-proxy DedupCoverage TCP server — coverage flows correctly; the score>=90 pair
dedupes (identical line sets), the score=40 case differs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A fresh JVM runs each class's static initializer (<clinit>) on first use.
JaCoCo charged those one-time lines to whichever test ran first, so a test
could look different from its true duplicates by luck of timing -> the
duplicate set flip-flopped run to run (e.g. 2 vs 4).

On the first START command (app fully started), eagerly Class.forName(...,
initialize=true) every indexed application class so all <clinit> lines run
once, then the normal reset clears them. Every test window then captures
only the lines its own request executes -> deterministic dedup. Best-effort
per class; disable via KEPLOY_JAVA_DEDUP_WARMUP_DISABLED=true. Go is
unaffected (AOT, init() runs once at startup before the first reset).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fingerprint each test by the set of executed JaCoCo probes per class
({className -> [probeIdx]}) instead of executed source lines. Each branch
is instrumented as a distinct probe, so the probe set distinguishes which
branch a test took (true vs false) on a shared line — line status and even
branch counts report identically for both paths. The probe set subsumes
line coverage and uses canonical VM class-name keys (no path normalization).
Only capture() changes; the wire payload, collector, store, and enterprise
compute stay generic over map[string][]int. Removed the now-dead line-decode
helpers (Analyzer/CoverageBuilder, executedLines, resolveSourcePath).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 29, 2026 09:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Java dynamic-dedup agent to support a TCP transport mode (intended for k8s / non-shared-/tmp setups) and changes how coverage fingerprints are produced/sent during replay.

Changes:

  • Add dynamic transport selection: unix-socket CommandServer (local/docker) vs TCP client CoverageTcpClient (k8s) based on KEPLOY_COVERAGE_ENDPOINT.
  • Add one-time “warmup” of indexed application classes on first START to reduce <clinit> noise in the first test window.
  • Change coverage capture to emit per-class executed JaCoCo probe indices (instead of source-file executed line numbers).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +713 to 722
boolean[] probes = executionData.getProbes();
Set<Integer> fired = new LinkedHashSet<>();
for (int i = 0; i < probes.length; i++) {
if (probes[i]) {
fired.add(i);
}
}
if (!fired.isEmpty()) {
raw.put(executionData.getName(), fired);
}
Comment on lines +516 to +524
Map<String, List<Integer>> executedLinesByFile = collector.capture();
if (executedLinesByFile.isEmpty()) {
log(Level.FINE, "No Java coverage lines collected for " + command.testId, null);
} else {
// COV must precede ACK: the collector reads lines sequentially,
// so the payload is recorded before the ACK releases the caller.
writeLine(out, "COV " + GSON.toJson(
new DedupPayload(command.testId, executedLinesByFile)));
}
Comment on lines +449 to +452
if (running.get()) {
log(Level.INFO, "Keploy dedup: TCP connect to " + host + ":" + port
+ " failed (" + e.getClass().getSimpleName() + ": " + e.getMessage() + "), retrying", null);
}
praagyajain and others added 5 commits June 29, 2026 15:34
- TCP END always emits COV before ACK (even when empty), mirroring the unix
  transport, so the line-oriented collector reads exactly one COV per END and
  cannot desync (Copilot comment).
- Reconnect failures log at INFO only on the first failure, then FINE, to
  avoid ~1/s log spam in k8s; reset on a successful connect (Copilot comment).
- smoke-javaagent.sh asserts the VM class-name key ("smoke/Work") instead of
  the old source-file key ("Work.java"), matching the probe-based fingerprint
  contract; fixes the JDK 8/17/21 smoke jobs.
… + bytecode

Option B for whole-test-set coverage: a pure Java tool that reconstructs
ExecutionData from a fired-probe union (the dedupFingerprints we already persist)
plus a build-constant {classId, probeCount} manifest, runs JaCoCo Analyzer over the
app bytecode, and sums line/branch/instr/method counters over all classes.

Reuses the fingerprints verbatim as input; no JaCoCo internal APIs (id + probeCount
come from the SDK's live ExecutionData). CoverageReporterSelfTest cross-checks the
reconstruct-from-union path against JaCoCo's direct .exec analysis — exact match on
the sample app (lines 78/105, branches 22/42, instr 452/525).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: praagyajain <praagyajain2002@gmail.com>
…e (Option B)

After warmup (all app classes loaded), the SDK ships — best-effort, async,
once-per-JVM — a zip of the indexed class bytecode plus a manifest
{vmClassName -> {classId(hex), probeCount}} to k8s-proxy via HTTP multipart
(KEPLOY_BYTECODE_UPLOAD_URL, tagged with KEPLOY_BUILD_TAG). classId/probeCount
come straight from the live ExecutionData, so the offline CoverageReporter needs
no JaCoCo internal APIs. HEAD exists-check uploads at most once per build tag.

Gated by the two envs (webhook-injected); absent => no-op, so dedup-only
deployments are unchanged. Fully non-fatal: never throws into the app or the
per-test dedup path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: praagyajain <praagyajain2002@gmail.com>
The bytecode/manifest upload targets k8s-proxy's in-cluster HTTPS control port
(:8080), whose cert is self-signed and may not chain to the app JVM's truststore.
Since the upload is a best-effort, cluster-internal data-plane call (like the
raw-TCP coverage collector), install a trust-all SSLSocketFactory for that HTTPS
call only. No effect on plain-HTTP endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: praagyajain <praagyajain2002@gmail.com>
Build a live manifest ({vmClassName -> {classId, probeCount}}) from the proven
per-test capture() path and, when it grows, send the app class bytecode as a
base64 "CLASSES ..." frame on the existing collector TCP connection (:36340)
right after the COV frame. This survives the replay agent's outbound mock
interception, which 502'd the earlier HTTPS upload. k8s-proxy uses this
bytecode + the unioned fingerprints to compute whole-test-set coverage offline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sneha0099

Copy link
Copy Markdown

Reviewed the SDK-side dynamic-dedup changes end to end (the JaCoCo probe capture, the new TCP transport, the bytecode + manifest export, and the offline CoverageReporter). Findings below were verified against the branch code, not from memory. Line numbers refer to files on this branch.

Executive summary

  • Overall assessment: REQUEST CHANGES
  • Risk level: MEDIUM to HIGH

The core design is sound: probe-set fingerprints for branch-aware dedup, a warmup pass for determinism, and offline coverage reconstruction from a probe union plus a per-class manifest. The self-test proving reconstruction equals JaCoCo's own analysis is a good instinct. But the PR ships a large block of dead code (including a trust-all TLS manager), re-transmits the full bytecode blob once per newly discovered class, and changes an existing wire payload's meaning while keeping its old field name. Several correctness edges (load-time-transformed classes, warmup side effects, COV-on-exception) are unguarded, and there are no automated tests.

Findings

[HIGH] Entire HTTP bytecode-upload path is dead code, and it carries a trust-all TLS manager. KeployDedupAgent.java:272-393. bytecodeAlreadyStored (272), postBytecode (295), readStream (332), writeAscii (345), relaxTlsIfHttps (349), trustAllFactory (353), and urlEncode (387) are all new in this PR and none of them is called from anywhere. The bytecode now ships via pollBytecodeFrame over the TCP collector channel instead. That leaves roughly 130 lines of unreachable code, including an X509TrustManager that accepts every certificate and a hostname verifier that returns true. Even though it is currently unreachable, committing a disabled-cert-validation path is a loaded gun: a future edit that re-wires postBytecode silently ships with TLS verification off. Remove the whole HTTP cluster; if the HEAD-then-POST idempotency is still wanted as a fallback, wire it in behind an explicit path and do not disable certificate validation.

[HIGH] The full class zip is rebuilt and re-sent every time the manifest grows. KeployDedupAgent.java:952-995. pollBytecodeFrame gates on size <= lastUploadedManifestSize (957), but the manifest grows one class at a time as new app classes are hit across tests, and zipIndexedClasses (982) always zips every indexed class regardless of manifest size. So the build-constant bytecode blob (potentially megabytes) is re-zipped and re-sent as a single base64 line on the TCP channel on every manifest-size increase, up to O(distinct classes hit) times over a replay. On the collector side each of these arrives as one giant readLine, forcing a multi-megabyte line buffer. The bytecode never changes; send the zip once (guard with a sent-once flag) and let only the manifest grow, or send the final manifest at stop.

[HIGH] The per-test payload changed meaning but kept the name executedLinesByFile. KeployDedupAgent.java:840-908 (capture now returns {vmClassName -> [probeIdx]}), 515, 663-672, 1488-1497. Both transports serialize through DedupPayload, whose field is executedLinesByFile, so the wire JSON still uses that key while the value is now probe indices keyed by VM class name, not source lines keyed by file. The local variable at 515 is named executedLinesByFile for the same probe data. This works only because the enterprise consumer (#2188) treats the map opaquely as key:int sets, so the naming is now actively misleading and the coupling is implicit. Rename the field and variables to reflect probes-by-class, and document the wire contract change explicitly since any other consumer of this format breaks silently.

[MEDIUM to HIGH] Warmup eagerly runs every app class's static initializer, opt-out and on the START path. KeployDedupAgent.java:787-803, invoked at 498 and 646 inside testCaseLock. Class.forName(name, true, loader) executes <clinit> for all indexed classes on the first START. Static initializers can open connections, spawn threads, register shutdown hooks, or fail; per-class throwables are swallowed (832) but the side effects already happened, and this runs in replay where fidelity matters. It also runs synchronously under the lock before the first START is ACKed, so on a large app the first START ACK can be delayed by seconds and may exceed the harness's START timeout. Make warmup opt-in rather than opt-out, or scope it, and move it off the ACK-blocking path.

[MEDIUM] On a capture exception, COV is not emitted before ACK, breaking the stated one-COV-per-END invariant. KeployDedupAgent.java:662-686. The comment at 667 says COV is always emitted before ACK, but if collector.capture() throws, control jumps to the catch (no COV, no CLASSES) and the finally writes only ACK. The TCP collector reads COV and ACK off the same stream, so a missing COV shifts the protocol and the collector may associate the next test's frames incorrectly. Emit an explicit empty COV in the failure path (or a distinct error token the collector understands) so the stream stays in lockstep.

[MEDIUM] Offline coverage undercounts classes transformed at load time. CoverageReporter.java:105-159 (analyzeAll at 141), manifest id captured at KeployDedupAgent.java:884-889. The reconstruction matches store entries to analyzed classes by JaCoCo's CRC64 class id. That id is computed from the bytes JaCoCo saw at instrumentation time; if another agent or a CGLIB/load-time-weaving path transformed the class, the live id will not match the on-disk .class bytes the Analyzer hashes, so the class is treated as never-hit and its covered probes are dropped from the numerator while it still counts toward the denominator. That silently deflates the reported coverage for Spring-style apps. Dedup itself is unaffected (probe sets are compared like-for-like), but the reported numbers can be wrong. Document this limitation and ideally detect and log id mismatches.

[MEDIUM] No automated tests; the self-test is a manual harness that skips the real serialization path. CoverageReporterSelfTest.java is a main() with System.exit, needs an external .exec and classes dir, and is not wired into CI. Its round-trip at lines 72-73 does Long.parseUnsignedLong(Long.toHexString(id), 16), which is an identity on the id and never exercises the Gson manifest/union serialization or the file reads the production compute path uses. There are no unit tests for CoverageReporter.compute, CoverageCollector.capture, or pollBytecodeFrame. Add a JUnit test with a small checked-in fixture (a tiny classes dir plus a synthetic manifest and union JSON) that goes through the real serialize and read path, and a protocol test for the TCP dispatch sequence.

[MEDIUM] All app class bytes are held in memory for the JVM lifetime, and the zip is built fully in memory. KeployDedupAgent.java:1392-1431, 1499-1510, 982-995. CoverageIndex reads every .class into ClassEntry.bytes and caches the list forever, and zipIndexedClasses accumulates the entire zip in a ByteArrayOutputStream. For a large app this is a persistent footprint inside the app JVM plus a transient full-zip allocation on each send. Since the bytes are only needed to build the zip once, read them lazily at zip time and drop them after, or stream the zip.

[LOW] Dead field. KeployDedupAgent.java:758. uploadInFlight is declared and never read or written. Remove it.

[LOW] Endpoint parsing does not handle IPv6. KeployDedupAgent.java:123-137 uses lastIndexOf(':') to split host and port, which mis-splits a bracketed or bare IPv6 literal. Fine if endpoints are always IPv4 or DNS names, but worth a guard or a comment stating the assumption.

Missing test cases

  • CoverageReporter.compute over a fixture classes dir with a manifest and union JSON, asserting the exact line and branch counts (through the real Gson and file path, not the id no-op).
  • A hit class present in the union but absent from the classes blob, and a class in the blob never hit, to lock the denominator behavior.
  • A class whose live id does not match the on-disk bytecode (load-time transform), asserting the intended behavior and that it is logged rather than silently dropped.
  • TCP dispatch protocol: START then END happy path, mismatched END id, and a capture failure, asserting the exact COV/ACK line sequence the collector expects.
  • pollBytecodeFrame sends at most once for a build-constant class set, and no frame when KEPLOY_BUILD_TAG is unset.
  • Warmup: a class whose <clinit> throws is skipped without aborting warmup, and warmup runs exactly once.

Breaking change and operational impact

  • Wire contract: the per-test coverage payload changed from source-file/line to VM-class/probe under the same executedLinesByFile key. This must land in lockstep with the enterprise consumer (#2188) and k8s-proxy (#574); any other reader of this payload breaks. Call this out in the PR.
  • New protocol surface: the TCP transport and the CLASSES <b64tag> <b64manifest> <b64zip> frame are a new contract shared with k8s-proxy #574; both sides must agree on framing and size limits (see the full-zip resend finding).
  • New configuration: KEPLOY_COVERAGE_ENDPOINT, KEPLOY_BUILD_TAG, KEPLOY_JAVA_DEDUP_WARMUP_DISABLED, KEPLOY_JAVA_DEDUP_DISABLED, KEPLOY_JAVA_DEDUP_DIAGNOSTICS. These should be documented (README only bumped a class-name reference).
  • Deployment risk: warmup runs arbitrary app static initializers in replay by default; that is the highest operational risk here for real applications.

Final verdict

Not ready to merge. The approach is right and the reconstruction idea is validated in principle, but the dead HTTP path (with trust-all TLS), the full-zip resend, and the renamed-but-not-renamed wire payload should be fixed before merge, and the warmup default and COV-on-exception edges need to be made safe. Add at least one automated test through the real serialization path. Once the dead code is gone, the zip is sent once, the payload is renamed and documented, and warmup is opt-in, this is close.

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.

3 participants