Skip to content

fix(agent): keep detached probes reconnectable; add Client.connectAndExitProbe - #967

Merged
jbachorik merged 3 commits into
developfrom
feat/client_stop_probe
Oct 5, 2026
Merged

jbachorik merged 3 commits into
developfrom
feat/client_stop_probe

Conversation

@jbachorik

@jbachorik jbachorik commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

What does this change do?

Reconnecting to a detached probe has never actually worked: btrace -r <probe-id> exit|event <PID> connected and reported success, then every command it sent was ignored. A probe's single inbound reader thread ended when its first client's stream closed, and RemoteClient.reconnect only swapped in the new transport. A client sending EXIT after reconnecting waited forever. This came up while adding a "stop a detached probe" tool to the BTrace MCP server (btraceio/agent-plugins).

Three commits:

  1. fix(agent): keep detached probes reconnectable. The reader treats the end of a client's stream as a detach. It drops only that transport and parks until reconnect() installs a new one, exiting once the probe shuts down. sock and protocol are now swapped as a pair under a dedicated lock, so a racing reconnect can never have its new socket closed. The v2 protocol reported end-of-stream as a plain IOException, which sent both the old and the new reader down the error path; it now throws EOFException (still an IOException).
  2. feat(client): Client.connectAndExitProbe(host, probeId). This is the public counterpart of the package-private -r <id> exit path, for library hosts like the MCP server. It returns once the agent confirms the exit and always closes the connection; an unknown probe id is an IOException.
  3. fix(client): derive BTrace home only from a btrace-client JAR. getExtensionApiClasspath()'s classpath fallback matched any entry whose path contained btrace-client. From a checkout or worktree named btrace-client-*, every entry matched, and an unrelated extensions/ directory leaked onto the compile classpath. That made EmbeddedExtensionIntegrationTest.leanEngineCannotLinkTheSameProbe fail on unmodified develop built in such a directory (reproduced). It now matches the file name only: btrace-client.jar or btrace-client-<suffix>.jar.

Related issue

N/A. Needed by the stop_detached_probe MCP tool in btraceio/agent-plugins (PR linked from there).

Scope and compatibility

  • I identified the affected module(s) and kept unrelated changes out of this PR.
  • This preserves the supported Java/runtime compatibility tiers, or the change is documented below.
  • This does not change the masked-JAR layout, class-loader boundary, or wire protocol.
  • If it does, I updated the relevant architecture documentation and verification plan.

Compatibility or migration notes:

  • New public API: io.btrace.client.Client#connectAndExitProbe(String, String).
  • BinaryProtocol end of stream is now EOFException. It's a subtype of the previous IOException with the same message, and the wire format is unchanged.
  • Behaviour change: a probe whose client went away keeps its -o output file open and keeps writing. Before, the reader's exit path closed it, so a detached probe silently lost file output. The file is still closed when the probe exits.
  • A detached probe now keeps one parked daemon reader thread (500 ms park loop) until it exits or a client reconnects.

Testing

  • Unit tests
  • Integration tests (if applicable)
  • spotlessCheck
  • Documentation/link or sample verification (if applicable)

Commands and results:

./gradlew clean spotlessCheck                       -> BUILD SUCCESSFUL
./gradlew :btrace-dist:build                        -> BUILD SUCCESSFUL
./gradlew test                                      -> 1512 passed, 0 failed, 119 skipped
./gradlew -Pintegration :integration-tests:test     -> 74 run, 0 failed, 2 skipped

New tests:

  • RemoteClientReconnectTest: fails before the agent fix (the EXIT from the reconnected client never reaches the probe) and passes after.
  • ClientTest.connectAndExitProbe*: covers the stop path and the unknown-probe path, and checks the socket is closed.
  • ClientTest.btraceHomeIsDerivedOnlyFromABtraceClientJar.
  • BTraceFunctionalTests.testStopDetachedProbe: deploys and detaches a real probe, stops it with connectAndExitProbe, and asserts it's gone from -lp.

Also verified end to end through the MCP server's e2e suite against a JAR built from this branch: 11 passed. Before the agent fix, the new stop case hung.

Notes:

  • Both launchTestApp functional tests now stop their target in finally. Before this, testOnMethodUnattended leaked a target that held port 2020, which was harmless only because no later test in the class needed that port.
  • :btrace-dist:build does not run the module unit tests (CLAUDE.md suggests it does), so ./gradlew test was run separately.

Documentation and release impact

  • User-facing documentation is updated, or no documentation change is needed.
  • Release notes/changelog are updated, or no release-note entry is needed. (Left to the maintainer: the CLI -r <id> exit|event fix probably deserves an entry.)
  • Samples, distribution contents, or published coordinates are updated if affected.
  • This change is safe to merge independently of a release, or the dependency is explained below.

Release notes / follow-up work: The agent-plugins stop_detached_probe tool depends on this landing on develop, since its e2e CI builds BTrace's default branch.

Final checklist

  • I reviewed the complete diff and removed unrelated changes.
  • New or changed behavior has appropriate tests, or the reason for not adding them is explained above.
  • User-facing behavior, APIs, samples, and documentation are consistent with this change.
  • I did not include generated build output, local configuration, credentials, or other accidental files.

🤖 Generated with Claude Code


This change is Reviewable

jbachorik and others added 3 commits October 5, 2026 11:17
Reconnecting to a detached probe (btrace -r <probe-id>) connected and
reported success, but every command sent afterwards was ignored: a
probe's single inbound reader thread ended when its first client's stream
closed, and RemoteClient.reconnect only swapped in the new transport. A
reconnecting client sending EXIT or EVENT waited forever.

The reader now treats the end of a client's stream as a detach: it drops
only that transport (under a lock pairing sock and protocol, so a racing
reconnect is never closed) and parks until reconnect installs a new one,
exiting once the probe shuts down. The v2 protocol reported end of stream
as a plain IOException, which took the error path in both the old and
new reader; it now throws EOFException (still an IOException). A side
effect: a detached probe no longer has its -o output file closed when its
client goes away.

Verified: new RemoteClientReconnectTest fails before and passes after;
./gradlew test -> 1512 passed, 0 failed (119 skipped); spotlessCheck
green; -Pintegration :integration-tests:test -> 74 run, 0 failed
(2 skipped), including the client-side stop of a detached probe added in
the following commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Library hosts such as the BTrace MCP server can list detached probes
(connectAndListProbes) but had no public way to stop one: the reconnect
path behind the CLI's -r <probe-id> exit is package-private.
connectAndExitProbe(host, probeId) reconnects, sends EXIT, returns once
the agent confirms it, and always closes the connection; an unknown probe
id surfaces as an IOException.

The functional tests' submit-and-detach flow moves into a helper shared
by testOnMethodUnattended and the new testStopDetachedProbe, and both now
stop their target so its agent releases the BTrace port for later tests.

Verified: ClientTest covers the stop and unknown-probe paths;
-Pintegration :integration-tests:test -> 74 run, 0 failed (2 skipped),
including testStopDetachedProbe against a real agent; ./gradlew test ->
1512 passed, 0 failed (119 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getExtensionApiClasspath's classpath fallback took any entry whose path
contained "btrace-client" for the client JAR. Run from a checkout or
worktree whose directory name contains that string, every entry matched,
and the client picked up an unrelated extensions/ directory as its
extension API classpath. That made the lean-engine case of
EmbeddedExtensionIntegrationTest fail on unmodified develop when built in
a directory named btrace-client-*. Match only the entry's file name,
btrace-client.jar or btrace-client-<suffix>.jar.

Verified: new ClientTest.btraceHomeIsDerivedOnlyFromABtraceClientJar;
EmbeddedExtensionIntegrationTest passes from a btrace-client-* worktree;
full integration suite 74 run, 0 failed (2 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jbachorik

Copy link
Copy Markdown
Collaborator Author

Consumer: btraceio/agent-plugins#12 (stop_detached_probe MCP tool), kept as a draft until this merges.

@jbachorik
jbachorik merged commit 2f2c30e into develop Oct 5, 2026
16 checks passed
@jbachorik
jbachorik deleted the feat/client_stop_probe branch October 5, 2026 09:52
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