Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions btrace-agent/src/main/java/io/btrace/agent/ClientContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ class ClientContext {

ClientContext(
Instrumentation instr, BTraceTransformer transformer, ArgsMap args, SharedSettings settings) {
// A client's SET_PARAMS command mutates these settings in place, so a context must never be
// built on the agent-wide instance: that would let one client's debug/dumpDir/outputFile,
// granted permissions and non-downgradable `trusted` flag leak into every other client and
// into the global transformer. Seed a per-client copy with Main#newClientSettings instead.
if (settings == SharedSettings.GLOBAL) {
throw new IllegalArgumentException(
"ClientContext must not share SharedSettings.GLOBAL; use Main#newClientSettings(..)"
+ " to seed an isolated per-client copy");
}
this.instr = instr;
this.transformer = transformer;
this.args = args;
Expand Down
62 changes: 58 additions & 4 deletions btrace-agent/src/main/java/io/btrace/agent/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,27 @@ public final class Main {
private static final SharedSettings settings = SharedSettings.GLOBAL;
private static final BTraceTransformer transformer =
new BTraceTransformer(new DebugSupport(settings));
// ClassFile API's StackMapGenerator can exhaust the default stack depth when processing
// complex methods in JDK 26+ (class-file major >= 70). Use 4 MB on those JVMs only; on
// older JVMs keep the default (0 = JVM-chosen) to avoid any OS-level stack-allocation
// overhead that was intermittently delaying test startup on JDK 8/11/21 CI machines.
static final long QUEUE_PROCESSOR_STACK_SIZE = resolveQueueProcessorStackSize();

static long resolveQueueProcessorStackSize() {
return resolveQueueProcessorStackSize(System.getProperty("java.class.version", "52.0"));
}

// Visible for testing.
static long resolveQueueProcessorStackSize(String classVersion) {
try {
int dot = classVersion.indexOf('.');
int major = Integer.parseInt(dot >= 0 ? classVersion.substring(0, dot) : classVersion);
return major >= 70 ? 4L * 1024 * 1024 : 0L;
} catch (RuntimeException ignored) {
return 0L;
}
}

// #BTRACE-42: Non-daemon thread prevents traced application from exiting
// Deliberately an anonymous class, not a lambda: this static field initializer runs in
// Main's <clinit>, unconditionally, before -javaagent premain()'s body even starts -- see
Expand All @@ -188,7 +209,8 @@ public final class Main {
new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread result = new Thread(r, "BTrace Command Queue Processor");
Thread result =
new Thread(null, r, "BTrace Command Queue Processor", QUEUE_PROCESSOR_STACK_SIZE);
result.setDaemon(true);
return result;
}
Expand Down Expand Up @@ -1457,8 +1479,7 @@ private static boolean loadBTraceScript(String filePath, boolean traceToStdOut)
return false;
}

SharedSettings clientSettings = new SharedSettings();
clientSettings.from(settings);
SharedSettings clientSettings = newClientSettings(settings);
clientSettings.setClientName(scriptName);
if (traceToStdOut) {
clientSettings.setOutputFile("::stdout");
Expand Down Expand Up @@ -1511,7 +1532,7 @@ private static void runServer(ControlServer endpoint) {
log.debug("client accepted from {}", sock.getRemoteSocketAddress());
}
authenticationToken = endpoint.copyAuthenticationToken();
ClientContext ctx = new ClientContext(inst, transformer, argMap, settings);
ClientContext ctx = newRemoteClientContext();
Client client =
RemoteClient.getClient(
ctx,
Expand Down Expand Up @@ -1569,6 +1590,39 @@ private static synchronized void shutdownServer() {
}
}

/**
* Seeds a fresh per-client settings instance from the agent baseline.
*
* <p>Every client - local and remote alike - must get its own copy. A client's {@code SET_PARAMS}
* command is applied by mutating its {@link ClientContext}'s settings in place, so handing out
* the shared {@link SharedSettings#GLOBAL} instance lets one client's {@code debug}, {@code
* dumpDir}, {@code outputFile}, granted permissions and - most importantly - {@code trusted} flag
* leak into every other client and into the global transformer. {@code trusted} is deliberately
* non-downgrading, so such a leak is permanent for the lifetime of the agent.
*
* @param baseline the agent-wide settings to seed from
* @return a new settings instance, independent of {@code baseline}
*/
static SharedSettings newClientSettings(SharedSettings baseline) {
SharedSettings clientSettings = new SharedSettings();
clientSettings.from(baseline);
return clientSettings;
}

/**
* Builds the context for a newly accepted remote connection.
*
* <p>Extracted from the accept loop so the settings isolation it depends on is reachable from a
* unit test: inside the loop it sits under a {@code catch (RuntimeException | IOException)} that
* would turn a re-introduced sharing bug into a per-connection warning rather than a test
* failure.
*
* @return a context carrying settings private to this connection
*/
static ClientContext newRemoteClientContext() {
return new ClientContext(inst, transformer, argMap, newClientSettings(settings));
}

private static Future<?> handleNewClient(Client client) {
// Deliberately an anonymous class, not a lambda: this method can run reachable from
// -javaagent premain(), before the JVM's own java.lang.invoke bootstrap is guaranteed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* Copyright (c) 2008, 2026, Jaroslav Bachorik <j.bachorik@btrace.io>.
* All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.btrace.agent;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.btrace.core.ArgsMap;
import io.btrace.core.SharedSettings;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

/**
* Every client must trace against its own {@link SharedSettings} copy.
*
* <p>A client's {@code SET_PARAMS} command is applied by {@code RemoteClient} as {@code
* ctx.getSettings().from(params)} - an in-place mutation. If contexts share the agent-wide
* instance, one client's parameters silently become every client's parameters. {@code trusted} is
* the dangerous case: {@link SharedSettings#from(Map)} ORs it in and never clears it, so a single
* client asking for {@code trusted} would permanently elevate the whole agent.
*
* <p>This isolation has been implemented and then accidentally reverted once already, with nothing
* failing. These tests exist so the next revert breaks the build.
*/
class ClientSettingsIsolationTest {

@Test
@DisplayName("per-client settings are a copy, not the agent-wide instance")
void clientSettingsAreDistinctFromBaseline() {
SharedSettings baseline = new SharedSettings();
SharedSettings clientSettings = Main.newClientSettings(baseline);

assertNotSame(baseline, clientSettings, "client settings must not alias the baseline");
assertNotSame(
SharedSettings.GLOBAL,
clientSettings,
"client settings must not alias the global instance");
}

@Test
@DisplayName("per-client settings inherit the agent baseline")
void clientSettingsInheritBaseline() {
SharedSettings baseline = new SharedSettings();
baseline.setDebug(true);
baseline.setOutputFile("agent-baseline.btrace");

SharedSettings clientSettings = Main.newClientSettings(baseline);

assertTrue(clientSettings.isDebug(), "baseline debug flag should be inherited");
assertEquals("agent-baseline.btrace", clientSettings.getOutputFile());
}

@Test
@DisplayName("one client's SET_PARAMS does not leak into the agent baseline")
void setParamsDoesNotLeakToBaseline() {
SharedSettings baseline = new SharedSettings();
SharedSettings clientSettings = Main.newClientSettings(baseline);

// Mirrors RemoteClient's handling of Command.SET_PARAMS.
clientSettings.from(setParams());

assertTrue(clientSettings.isDebug(), "the requesting client should see its own parameters");
assertFalse(baseline.isDebug(), "debug leaked into the agent baseline");
assertFalse(baseline.isTrusted(), "trusted leaked into the agent baseline");
assertEquals(null, baseline.getDumpDir(), "dumpDir leaked into the agent baseline");
}

@Test
@DisplayName("one client's SET_PARAMS does not leak into another client")
void setParamsDoesNotLeakBetweenClients() {
SharedSettings baseline = new SharedSettings();
SharedSettings first = Main.newClientSettings(baseline);
SharedSettings second = Main.newClientSettings(baseline);

first.from(setParams());

assertTrue(first.isTrusted(), "the requesting client should see its own parameters");
assertFalse(second.isTrusted(), "trusted leaked into a concurrently connected client");
assertFalse(second.isDebug(), "debug leaked into a concurrently connected client");
}

@Test
@DisplayName("trusted cannot be downgraded, so a leak would be permanent")
void trustedIsNonDowngrading() {
SharedSettings settings = new SharedSettings();
settings.from(setParams());
assertTrue(settings.isTrusted());

Map<String, Object> untrust = new HashMap<>();
untrust.put(SharedSettings.TRUSTED_KEY, Boolean.FALSE);
settings.from(untrust);

assertTrue(
settings.isTrusted(),
"trusted is deliberately non-downgrading - which is exactly why sharing the instance"
+ " between clients is unsafe");
}

@Test
@DisplayName("a ClientContext cannot be built on the agent-wide settings")
void clientContextRejectsGlobalSettings() {
IllegalArgumentException failure =
assertThrows(
IllegalArgumentException.class,
() -> new ClientContext(null, null, new ArgsMap(), SharedSettings.GLOBAL));

assertTrue(
failure.getMessage().contains("newClientSettings"),
"the failure should point at the supported way to seed per-client settings");
}

@Test
@DisplayName("a ClientContext accepts an isolated copy")
void clientContextAcceptsIsolatedSettings() {
ClientContext ctx =
new ClientContext(null, null, new ArgsMap(), Main.newClientSettings(SharedSettings.GLOBAL));

assertNotSame(SharedSettings.GLOBAL, ctx.getSettings());
}

@Test
@DisplayName("the remote accept path builds contexts on isolated settings")
void remoteContextDoesNotShareGlobalSettings() {
ClientContext ctx = Main.newRemoteClientContext();

assertNotSame(
SharedSettings.GLOBAL,
ctx.getSettings(),
"the remote accept path handed out the agent-wide settings instance");
}

@Test
@DisplayName("two remote connections do not share settings with each other")
void remoteContextsAreIndependent() {
ClientContext first = Main.newRemoteClientContext();
ClientContext second = Main.newRemoteClientContext();

assertNotSame(first.getSettings(), second.getSettings());

first.getSettings().from(setParams());

assertFalse(second.getSettings().isTrusted(), "trusted leaked between remote connections");
assertFalse(SharedSettings.GLOBAL.isTrusted(), "trusted leaked into the agent-wide settings");
}

private static Map<String, Object> setParams() {
Map<String, Object> params = new HashMap<>();
params.put(SharedSettings.DEBUG_KEY, Boolean.TRUE);
params.put(SharedSettings.TRUSTED_KEY, Boolean.TRUE);
params.put(SharedSettings.DUMP_DIR_KEY, "/tmp/leaked-dump-dir");
return params;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2008, 2026, Jaroslav Bachorik <j.bachorik@btrace.io>.
* All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.btrace.agent;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

/**
* The command-queue processor thread needs an enlarged stack on JDK 26+.
*
* <p>ClassFile API's {@code StackMapGenerator} can exhaust the default stack depth while processing
* complex methods on class-file major 70 and above. Older JVMs keep the JVM-chosen default, because
* an explicit stack size there was intermittently delaying test startup on JDK 8/11/21 CI machines.
*
* <p>This sizing has been dropped by an accidental revert once already; these tests pin both halves
* of the trade-off.
*/
class QueueProcessorStackSizeTest {

private static final long FOUR_MB = 4L * 1024 * 1024;

@Test
@DisplayName("JDK 26+ class files get an enlarged stack")
void enlargedStackOnJdk26AndLater() {
assertEquals(FOUR_MB, Main.resolveQueueProcessorStackSize("70.0"), "JDK 26 (major 70)");
assertEquals(FOUR_MB, Main.resolveQueueProcessorStackSize("71.0"), "JDK 27 (major 71)");
assertEquals(FOUR_MB, Main.resolveQueueProcessorStackSize("99"), "far-future JVM");
}

@Test
@DisplayName("older JVMs keep the JVM-chosen default")
void defaultStackBelowJdk26() {
assertEquals(0L, Main.resolveQueueProcessorStackSize("52.0"), "JDK 8 (major 52)");
assertEquals(0L, Main.resolveQueueProcessorStackSize("55.0"), "JDK 11 (major 55)");
assertEquals(0L, Main.resolveQueueProcessorStackSize("65.0"), "JDK 21 (major 65)");
assertEquals(0L, Main.resolveQueueProcessorStackSize("69.0"), "JDK 25 (major 69)");
}

@Test
@DisplayName("an unparseable class version falls back to the default")
void malformedClassVersionFallsBack() {
assertEquals(0L, Main.resolveQueueProcessorStackSize(""));
assertEquals(0L, Main.resolveQueueProcessorStackSize("not-a-version"));
assertEquals(0L, Main.resolveQueueProcessorStackSize(".7"));
}

@Test
@DisplayName("the constant is wired to the running JVM's class version")
void constantMatchesRunningJvm() {
assertEquals(
Main.resolveQueueProcessorStackSize(System.getProperty("java.class.version", "52.0")),
Main.QUEUE_PROCESSOR_STACK_SIZE,
"QUEUE_PROCESSOR_STACK_SIZE must be derived from java.class.version");
}
}
Loading
Loading