.csv
+# Example: /mnt/shared/vm-power.csv-4242.csv
+vm-power-file=
+
+# The format of the virtual machine file (if power-source-type is vm)
+# Options:
+# powerjoular (the row PowerJoular rewrites every second with -o for a process: timestamp,cpu_usage,cpu_power)
+# watts (the power alone, in watts, used as it is)
+# Default if left empty: powerjoular
+vm-power-format=
+
+# The sample rate (milliseconds) for the agent to monitor methods usage by calling the JVM stack
+# Higher count means less accurate energy data
+# Lower count means higher overhead of the agent
+# From 1 to 1000
+# Default if left empty: 10 (good balance between accuracy and efficiency)
+stack-monitoring-sample-rate=
+
+# Output folder for results
+# Default if left empty: joular-code-java-results
+results-path=
+
+# Only frames whose package.class.method starts with one of these prefixes are filtered and calculated and written to methods-power-app.csv
+# methods-power-all.csv always have every branch (including those of the JDK)
+# Several prefixes are separated by a comma, e.g. com.example,org.myapp; any match counts
+# Default if left empty: no filter (all branches go to both files)
+methods-filtering-prefix=
diff --git a/pom.xml b/pom.xml
index a51986d..97625c7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,177 +1,95 @@
-
-
-
-
-
- 4.0.0
-
- org.noureddine.joular
- joularcodejava
- 0.0.1
- jar
-
- ${project.artifactId}
- Joular Code Java is a lightweight and efficient Java agent for monitoring the energy consumption of methods and executions branches
- 2026
- https://github.com/joular/joularcode-java
-
-
-
- The GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- https://www.gnu.org/licenses/lgpl-3.0.en.html
-
-
-
-
-
- Adel Noureddine
- adel.noureddine@outlook.com
-
-
-
-
- scm:git:git://github.com/joular/joularcode-java.git
- scm:git:ssh://github.com:joular/joularcode-java.git
- https://github.com/joular/joularcode-java
-
-
-
- 21
- UTF-8
- UTF-8
- 5.23.0
-
-
-
-
- net.java.dev.jna
- jna
- 5.18.1
-
-
- tools.jackson.core
- jackson-core
- 3.1.3
-
-
- org.junit.jupiter
- junit-jupiter
- 6.0.3
- test
-
-
- org.mockito
- mockito-junit-jupiter
- ${mockito.version}
- test
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-enforcer-plugin
- 3.6.2
-
-
- enforce-java-version
-
- enforce
-
-
-
-
- [21,)
- Joular Code - Java requires Java 21 or later.
-
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-compiler-plugin
- 3.15.0
-
-
- org.apache.maven.plugins
- maven-surefire-plugin
- 3.5.5
-
- -Xshare:off -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar -Dnet.bytebuddy.experimental=true --enable-native-access=ALL-UNNAMED
- true
-
-
-
- org.apache.maven.plugins
- maven-jar-plugin
- 3.5.0
-
- true
-
-
- org.noureddine.joular.joularcodejava.agent.Agent
- org.noureddine.joular.joularcodejava.agent.Agent
- true
- true
- ${project.version}
-
-
-
-
-
- org.apache.maven.plugins
- maven-shade-plugin
- 3.6.2
-
-
- package
-
- shade
-
-
- false
- false
-
-
- *:*
-
- META-INF/MANIFEST.MF
- META-INF/LICENSE
- module-info.class
- META-INF/versions/**/module-info.class
-
-
-
-
-
-
- org.noureddine.joular.joularcodejava.agent.Agent
- org.noureddine.joular.joularcodejava.agent.Agent
- true
- true
- ${project.version}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ 4.0.0
+
+ org.noureddine.joular
+ joularcodejava
+ 0.0.1
+ jar
+
+ ${project.artifactId}
+ Joular Code for Java is a lightweight and efficient Java agent for monitoring the energy consumption of methods and execution branches
+ 2026
+ https://github.com/joular/joularcode-java
+
+
+
+ The GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ https://www.gnu.org/licenses/lgpl-3.0.en.html
+
+
+
+
+
+ Adel Noureddine
+ adel.noureddine@outlook.com
+
+
+
+
+ scm:git:git://github.com/joular/joularcode-java.git
+ scm:git:ssh://github.com:joular/joularcode-java.git
+ https://github.com/joular/joularcode-java
+
+
+
+ 21
+ UTF-8
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 6.1.3
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.16.0
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.6.0
+
+ true
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.5.1
+
+ true
+
+
+ org.noureddine.joular.joularcodejava.agent.Agent
+ org.noureddine.joular.joularcodejava.agent.Agent
+ ${project.version}
+
+
+
+
+
+
+
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java
index 25dd13a..88be6e1 100644
--- a/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java
+++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/Agent.java
@@ -11,150 +11,100 @@
package org.noureddine.joular.joularcodejava.agent;
-import java.lang.instrument.Instrumentation;
-import java.lang.management.ManagementFactory;
-import java.lang.management.OperatingSystemMXBean;
-import java.lang.management.ThreadMXBean;
import java.io.PrintWriter;
import java.io.StringWriter;
+import java.lang.instrument.Instrumentation;
import java.util.Locale;
+import java.util.Objects;
+import java.util.logging.ConsoleHandler;
import java.util.logging.Formatter;
-import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
-import org.noureddine.joular.joularcodejava.agent.source.PowerSource;
-import org.noureddine.joular.joularcodejava.agent.source.PowerSourceFactory;
-import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties;
/**
- * Main Joular Code - Java entry point.
+ * Entry point of Joular Code for Java, as {@code -javaagent:joularcodejava.jar} or loaded into a running JVM through the Attach API.
+ * Reads the configuration and starts the {@link Monitor}.
*/
-public class Agent {
+public final class Agent {
- public static final String AGENT_THREAD_NAME = "Joular-Agent-Monitor";
- private static final Logger logger = Logger.getLogger(
- Agent.class.getName());
+ private static final Logger logger = Logger.getLogger(Agent.class.getName());
- private static void printJoularCodeJavaBanner() {
- String version = Agent.class.getPackage() != null
- ? Agent.class.getPackage().getImplementationVersion()
- : null;
- if (version == null || version.isEmpty()) {
- version = "unknown";
- }
- String welcomeMessage = "Joular Code - Java: version " + version;
- boolean noColor = System.getenv("NO_COLOR") != null;
+ // Held in a field because java.util.logging only keeps weak references, and would lose the handler with the logger
+ private static final Logger joularLogger = Logger.getLogger("org.noureddine.joular");
- if (noColor) {
- System.out.println(welcomeMessage);
- return;
- }
+ private static boolean loggingConfigured = false;
- String boldYellow = "\u001B[1;33m";
- String reset = "\u001B[0m";
+ /** The last monitor started, or null. Guarded by the class lock, since the Attach API can load the agent again. */
+ private static Monitor activeMonitor;
- System.out.println(boldYellow + welcomeMessage + reset);
+ private Agent() {
}
+ /** Called when the agent is given on the command line. */
public static void premain(String args, Instrumentation inst) {
- configureLogging();
- printJoularCodeJavaBanner();
- logger.log(Level.INFO, "Initializing Joular Code Java...");
+ start();
+ }
- AgentProperties properties = new AgentProperties();
- PowerSource powerSource = PowerSourceFactory.getPowerSource(properties);
+ /** Called when the agent is loaded into a running JVM. Only what happens after that is measured. */
+ public static void agentmain(String args, Instrumentation inst) {
+ start();
+ }
- if (powerSource == null) {
- logger.log(Level.SEVERE, "No valid power source found. Agent will not start.");
+ private static synchronized void start() {
+ if (activeMonitor != null && activeMonitor.isAlive()) {
+ logger.log(Level.WARNING, "Joular Code for Java is already monitoring this JVM. Ignoring this attach.");
return;
}
-
- ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
- if (threadBean.isThreadCpuTimeSupported()) {
- if (!threadBean.isThreadCpuTimeEnabled()) {
- threadBean.setThreadCpuTimeEnabled(true);
- }
- } else {
+ // Nothing may escape: an exception thrown out of premain stops the application from running at all
+ try {
+ configureLogging();
+ printBanner();
+
+ Config config = Config.load();
+ Monitor monitor = new Monitor(config, () -> PowerSource.open(config));
+ monitor.start();
+ Runtime.getRuntime().addShutdownHook(new Thread(monitor::stop, "Joular-Agent-Shutdown"));
+ activeMonitor = monitor;
+ logger.log(Level.INFO, "Joular Code for Java started. Results are written to " + config.resultsDir().toAbsolutePath());
+ } catch (Throwable t) {
logger.log(Level.SEVERE,
- "Thread CPU time is not supported on this JVM. Joular Code - Java will not start.");
- return;
+ "Joular Code for Java could not start. The application is unaffected and carries on without monitoring.", t);
}
+ }
- OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
- if (!(osBean instanceof com.sun.management.OperatingSystemMXBean sunOsBean)) {
- logger.log(Level.SEVERE,
- "Unsupported JVM: requires com.sun.management.OperatingSystemMXBean for CPU-load metrics. Joular Code - Java will not start.");
- return;
- }
-
- // Warm-up OS Bean to avoid initial negative readings
- logger.log(Level.INFO, "Warming up OS bean...");
- for (int i = 0; i < 2; i++) {
- sunOsBean.getCpuLoad();
- sunOsBean.getProcessCpuLoad();
- try {
- Thread.sleep(500);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
-
- MonitoringHandler monitoringHandler = new MonitoringHandler(
- properties,
- powerSource,
- threadBean,
- sunOsBean);
- Thread monitoringThread = new Thread(
- monitoringHandler,
- AGENT_THREAD_NAME);
- monitoringThread.setDaemon(true);
- monitoringThread.start();
-
- boolean startupSignaled = monitoringHandler.awaitStartup(3000);
- if (startupSignaled && !monitoringHandler.isStartupSuccessful()) {
- Throwable startupFailure = monitoringHandler.getStartupFailure();
- logger.log(Level.SEVERE, "Monitoring thread failed during startup.", startupFailure);
+ /** Sends the agent's own log records to stderr, without touching how the application logs. */
+ private static void configureLogging() {
+ if (loggingConfigured) {
return;
}
- if (!startupSignaled) {
- logger.log(Level.WARNING,
- "Monitoring startup could not be confirmed within 3 seconds. The monitoring thread may still be initializing.");
- }
-
- Runtime.getRuntime().addShutdownHook(
- new Thread(() -> {
- logger.log(Level.INFO, "Stopping Joular Code Java...");
- monitoringHandler.stop();
- monitoringHandler.joinWithTimeout(2000);
- }));
+ ConsoleHandler handler = new ConsoleHandler();
+ handler.setFormatter(new LogFormatter());
+ // Let everything through: the logger's level decides what is published
+ handler.setLevel(Level.ALL);
+ joularLogger.addHandler(handler);
+ joularLogger.setUseParentHandlers(false);
+ loggingConfigured = true;
+ }
- logger.log(Level.INFO, "Joular Code Java started successfully.");
+ private static void printBanner() {
+ String version = Objects.requireNonNullElse(Agent.class.getPackage().getImplementationVersion(), "unknown");
+ // stderr, like the logs: the application's stdout may be a data stream
+ System.err.println("Joular Code for Java: version " + version);
}
- private static void configureLogging() {
- Logger root = Logger.getLogger("");
- for (Handler handler : root.getHandlers()) {
- handler.setFormatter(
- new Formatter() {
- @Override
- public String format(LogRecord record) {
- StringBuilder formatted = new StringBuilder(String.format(
- Locale.ROOT,
- "%1$td-%1$tm-%1$tY %1$tH:%1$tM:%1$tS %2$s: %3$s%n",
- record.getMillis(),
- record.getLevel().getName(),
- formatMessage(record)));
- if (record.getThrown() != null) {
- StringWriter sw = new StringWriter();
- PrintWriter pw = new PrintWriter(sw);
- record.getThrown().printStackTrace(pw);
- pw.flush();
- formatted.append(sw.toString());
- }
- return formatted.toString();
- }
- });
+ /** {@code dd-MM-yyyy HH:mm:ss LEVEL: message}, followed by the stack trace if there is one. */
+ private static final class LogFormatter extends Formatter {
+ @Override
+ public String format(LogRecord record) {
+ String line = String.format(Locale.ROOT, "%1$td-%1$tm-%1$tY %1$tH:%1$tM:%1$tS %2$s: %3$s%n",
+ record.getMillis(), record.getLevel().getName(), formatMessage(record));
+ if (record.getThrown() == null) {
+ return line;
+ }
+ StringWriter stackTrace = new StringWriter();
+ record.getThrown().printStackTrace(new PrintWriter(stackTrace));
+ return line + stackTrace;
}
}
}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/Branch.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/Branch.java
new file mode 100644
index 0000000..8854a6d
--- /dev/null
+++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/Branch.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import java.util.List;
+
+/**
+ * A call branch: the stack of a thread, as the thread dump gives it (newest frame first).
+ *
+ * Two stacks are the same branch when they go through the same methods, whatever the line numbers.
+ * Comparing frames is cheap next to building text, so the stacks are counted as they are, on every sample, and only written out as text once per window.
+ */
+final class Branch {
+
+ private final StackTraceElement[] frames;
+ private final int hash;
+
+ private Branch(StackTraceElement[] frames, int hash) {
+ this.frames = frames;
+ this.hash = hash;
+ }
+
+ static Branch of(StackTraceElement[] frames) {
+ int hash = 1;
+ for (StackTraceElement frame : frames) {
+ hash = 31 * (31 * hash + frame.getClassName().hashCode()) + frame.getMethodName().hashCode();
+ }
+ return new Branch(frames, hash);
+ }
+
+ /** Every frame, oldest first, as {@code a.Main.main;a.Worker.compute}. */
+ String name() {
+ return join(List.of());
+ }
+
+ /** Only the frames that start with one of the prefixes, oldest first; empty when none does. */
+ String appName(List prefixes) {
+ return join(prefixes);
+ }
+
+ private String join(List prefixes) {
+ StringBuilder name = new StringBuilder();
+ for (int i = frames.length - 1; i >= 0; i--) {
+ StackTraceElement frame = frames[i];
+ if (!prefixes.isEmpty() && !matchesAny(frame, prefixes)) {
+ continue;
+ }
+ if (!name.isEmpty()) {
+ name.append(';');
+ }
+ name.append(frame.getClassName()).append('.').append(frame.getMethodName());
+ }
+ return name.toString();
+ }
+
+ private static boolean matchesAny(StackTraceElement frame, List prefixes) {
+ for (String prefix : prefixes) {
+ if (startsWith(frame.getClassName(), frame.getMethodName(), prefix)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Whether {@code className + "." + methodName} starts with {@code prefix}, without building that string.
+ * The prefix is plain text, not a package boundary: {@code com.example} also matches {@code com.examples}.
+ */
+ static boolean startsWith(String className, String methodName, String prefix) {
+ int classLength = className.length();
+ if (prefix.length() <= classLength) {
+ return className.startsWith(prefix);
+ }
+ return prefix.startsWith(className)
+ && prefix.charAt(classLength) == '.'
+ && methodName.regionMatches(0, prefix, classLength + 1, prefix.length() - classLength - 1);
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof Branch that) || hash != that.hash || frames.length != that.frames.length) {
+ return false;
+ }
+ for (int i = 0; i < frames.length; i++) {
+ if (!frames[i].getClassName().equals(that.frames[i].getClassName())
+ || !frames[i].getMethodName().equals(that.frames[i].getMethodName())) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ return hash;
+ }
+
+ @Override
+ public String toString() {
+ return name();
+ }
+}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/Config.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/Config.java
new file mode 100644
index 0000000..dd04470
--- /dev/null
+++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/Config.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.Properties;
+import java.util.function.Predicate;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * The agent's configuration, read from {@code joularcodejava.properties}.
+ *
+ * The file is the one named by the {@code joularcodejava.properties} system property, or else the one in the
+ * working directory. A missing file or an empty value means the default.
+ *
+ * @param powerSourceType {@code auto}, {@code rapl}, {@code ringbuffer} or {@code vm}
+ * @param ringBufferPath the ring buffer PowerJoular writes with {@code -r}
+ * @param vmPowerFile the file the host writes this virtual machine's power to, or null when not set
+ * @param vmPowerFormat {@code powerjoular} or {@code watts}, the format of {@code vmPowerFile}
+ * @param sampleRateMs how often the stacks are sampled, in milliseconds
+ * @param resultsDir where the CSV files are written
+ * @param appPrefixes what a frame must start with to be in the app file; empty means no filter
+ */
+record Config(String powerSourceType, Path ringBufferPath, Path vmPowerFile, String vmPowerFormat,
+ long sampleRateMs, Path resultsDir, List appPrefixes) {
+
+ private static final Logger logger = Logger.getLogger(Config.class.getName());
+
+ static final long DEFAULT_SAMPLE_RATE_MS = 10;
+ private static final long LOW_SAMPLE_RATE_MS = 5;
+ // Samples past a window's length would stretch the window itself
+ private static final long MAX_SAMPLE_RATE_MS = 1000;
+
+ static Config load() {
+ String configuredPath = System.getProperty("joularcodejava.properties", "");
+ Path file = Path.of(configuredPath.isBlank() ? "joularcodejava.properties" : configuredPath);
+
+ Properties properties = new Properties();
+ if (!Files.isRegularFile(file)) {
+ logger.log(Level.INFO, "No configuration file at " + file.toAbsolutePath() + ", using defaults.");
+ } else {
+ try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
+ properties.load(reader);
+ } catch (IOException e) {
+ logger.log(Level.WARNING, "Could not read " + file.toAbsolutePath() + ", using defaults.", e);
+ }
+ }
+ return from(properties);
+ }
+
+ static Config from(Properties properties) {
+ String vmPowerFile = get(properties, "vm-power-file", "");
+ return new Config(
+ get(properties, "power-source-type", "auto").toLowerCase(Locale.ROOT),
+ Path.of(get(properties, "powerjoular-ringbuffer-path", defaultRingBufferPath())),
+ vmPowerFile.isEmpty() ? null : Path.of(vmPowerFile),
+ get(properties, "vm-power-format", "powerjoular").toLowerCase(Locale.ROOT),
+ sampleRate(get(properties, "stack-monitoring-sample-rate", "")),
+ Path.of(get(properties, "results-path", "joular-code-java-results")),
+ prefixes(get(properties, "methods-filtering-prefix", "")));
+ }
+
+ private static String get(Properties properties, String key, String defaultValue) {
+ String value = properties.getProperty(key, "").trim();
+ return value.isEmpty() ? defaultValue : value;
+ }
+
+ private static long sampleRate(String value) {
+ if (value.isEmpty()) {
+ return DEFAULT_SAMPLE_RATE_MS;
+ }
+ try {
+ long rate = Long.parseLong(value);
+ if (rate > 0 && rate <= MAX_SAMPLE_RATE_MS) {
+ if (rate < LOW_SAMPLE_RATE_MS) {
+ logger.log(Level.WARNING, "stack-monitoring-sample-rate is " + rate + " ms. Below "
+ + LOW_SAMPLE_RATE_MS + " ms, sampling can noticeably slow the application down.");
+ }
+ return rate;
+ }
+ } catch (NumberFormatException ignored) {
+ // Reported below, like a value out of range
+ }
+ logger.log(Level.WARNING, "stack-monitoring-sample-rate must be a number of milliseconds from 1 to "
+ + MAX_SAMPLE_RATE_MS + ", not '" + value + "'. Using " + DEFAULT_SAMPLE_RATE_MS + " ms.");
+ return DEFAULT_SAMPLE_RATE_MS;
+ }
+
+ private static List prefixes(String value) {
+ return Arrays.stream(value.split(","))
+ .map(String::trim)
+ .filter(Predicate.not(String::isEmpty))
+ .toList();
+ }
+
+ /** Where PowerJoular puts its ring buffer on each OS. */
+ private static String defaultRingBufferPath() {
+ String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
+ if (os.contains("win")) {
+ String programData = System.getenv("PROGRAMDATA");
+ return (programData == null || programData.isBlank() ? "C:\\ProgramData" : programData) + "\\powerjoular";
+ }
+ return os.contains("mac") ? "/tmp/powerjoular" : "/dev/shm/powerjoular";
+ }
+}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/LinuxRapl.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/LinuxRapl.java
new file mode 100644
index 0000000..d710d5c
--- /dev/null
+++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/LinuxRapl.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import java.io.IOException;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.LongSupplier;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Reads the energy of the CPU packages straight from the Linux powercap RAPL interface.
+ *
+ * Every package zone is summed, so a machine with several sockets is measured whole.
+ * A package zone is a {@code /sys/class/powercap/intel-rapl:N} whose {@code name} starts with {@code package}, on AMD processors too.
+ * Its sub-zones ({@code intel-rapl:N:M}: cores, uncore, DRAM) are left out, as is {@code psys}, which covers more than the CPU.
+ */
+final class LinuxRapl implements PowerSource {
+
+ private static final Logger logger = Logger.getLogger(LinuxRapl.class.getName());
+
+ private static final Path POWERCAP = Path.of("/sys/class/powercap");
+
+ /** One package's energy counter, in microjoules. */
+ private static final class Zone {
+ final Path energyFile;
+ final long maxEnergyUj;
+ long lastEnergyUj;
+
+ Zone(Path energyFile, long maxEnergyUj) {
+ this.energyFile = energyFile;
+ this.maxEnergyUj = maxEnergyUj;
+ }
+ }
+
+ private final List zones;
+ private final LongSupplier nanoTime;
+ private long lastReadNs;
+
+ private LinuxRapl(List zones, LongSupplier nanoTime) {
+ this.zones = zones;
+ this.nanoTime = nanoTime;
+ this.lastReadNs = nanoTime.getAsLong();
+ }
+
+ /** Whether this is Linux with RAPL, readable or not. */
+ static boolean isPresent() {
+ // Checked in this order: Path.of rejects the ':' of the zone name on Windows
+ return isLinux() && Files.isDirectory(POWERCAP.resolve("intel-rapl:0"));
+ }
+
+ static LinuxRapl open() throws IOException {
+ if (!isLinux()) {
+ throw new IOException("power-source-type=rapl only works on Linux. Use auto or ringbuffer instead.");
+ }
+ return open(POWERCAP, System::nanoTime);
+ }
+
+ static LinuxRapl open(Path powercapDir, LongSupplier nanoTime) throws IOException {
+ List zones;
+ try {
+ zones = packageZones(powercapDir);
+ for (Zone zone : zones) {
+ zone.lastEnergyUj = readEnergy(zone);
+ }
+ } catch (AccessDeniedException e) {
+ throw new IOException("Cannot read " + e.getFile() + ". RAPL is only readable by root on most Linux"
+ + " systems: run the application as root, give its user read access to the energy_uj files, or"
+ + " run PowerJoular with -r as root and set power-source-type=ringbuffer.", e);
+ }
+ logger.log(Level.INFO, "Reading CPU power from Linux RAPL: "
+ + zones.stream().map(zone -> zone.energyFile.getParent().toString()).toList());
+ return new LinuxRapl(zones, nanoTime);
+ }
+
+ private static List packageZones(Path powercapDir) throws IOException {
+ List zones = new ArrayList<>();
+ try (DirectoryStream entries = Files.newDirectoryStream(powercapDir, "intel-rapl:*")) {
+ for (Path zoneDir : entries) {
+ boolean topLevel = zoneDir.getFileName().toString().matches("intel-rapl:\\d+");
+ if (topLevel && Files.readString(zoneDir.resolve("name")).trim().startsWith("package")) {
+ zones.add(new Zone(zoneDir.resolve("energy_uj"),
+ parseMicrojoules(zoneDir.resolve("max_energy_range_uj"))));
+ }
+ }
+ }
+ if (zones.isEmpty()) {
+ throw new IOException("No RAPL package zone found in " + powercapDir + ".");
+ }
+ zones.sort(Comparator.comparing(zone -> zone.energyFile));
+ return zones;
+ }
+
+ @Override
+ public double watts() throws IOException {
+ // Every counter is read before any is updated, so a failure leaves them all where they were
+ long[] energyUj = new long[zones.size()];
+ for (int i = 0; i < energyUj.length; i++) {
+ energyUj[i] = readEnergy(zones.get(i));
+ }
+ long readNs = nanoTime.getAsLong();
+ long elapsedNs = readNs - lastReadNs;
+ if (elapsedNs <= 0) {
+ throw new IOException("RAPL was read twice at the same instant.");
+ }
+
+ long usedUj = 0;
+ for (int i = 0; i < energyUj.length; i++) {
+ Zone zone = zones.get(i);
+ usedUj += energyBetween(zone.lastEnergyUj, energyUj[i], zone.maxEnergyUj);
+ zone.lastEnergyUj = energyUj[i];
+ }
+ lastReadNs = readNs;
+ return (usedUj / 1e6) / (elapsedNs / 1e9);
+ }
+
+ @Override
+ public void close() {
+ }
+
+ /** The energy used between two readings of a counter that wraps back to 0 past {@code maxUj}. */
+ static long energyBetween(long beforeUj, long afterUj, long maxUj) {
+ if (afterUj >= beforeUj) {
+ return afterUj - beforeUj;
+ }
+ long wrapped = maxUj - beforeUj + afterUj;
+ // More than half the range in one window is not a wrap: the counter was reset, e.g. after suspend
+ return wrapped > maxUj / 2 ? 0 : wrapped;
+ }
+
+ private static long readEnergy(Zone zone) throws IOException {
+ long energy = parseMicrojoules(zone.energyFile);
+ if (energy > zone.maxEnergyUj) {
+ throw new IOException("RAPL counter " + zone.energyFile + " is above its range: " + energy + " uJ.");
+ }
+ return energy;
+ }
+
+ /** Reads a file holding a number of microjoules, which cannot be negative. */
+ private static long parseMicrojoules(Path file) throws IOException {
+ String text = Files.readString(file).trim();
+ try {
+ long value = Long.parseLong(text);
+ if (value >= 0) {
+ return value;
+ }
+ } catch (NumberFormatException ignored) {
+ // Reported below, like a negative value
+ }
+ throw new IOException("Unexpected value in " + file + ": '" + text + "'.");
+ }
+
+ private static boolean isLinux() {
+ return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("linux");
+ }
+}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/Monitor.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/Monitor.java
new file mode 100644
index 0000000..c2c0606
--- /dev/null
+++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/Monitor.java
@@ -0,0 +1,252 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import com.sun.management.OperatingSystemMXBean;
+import com.sun.management.ThreadMXBean;
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.LockSupport;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.noureddine.joular.joularcodejava.agent.PowerModel.CpuUsage;
+import org.noureddine.joular.joularcodejava.agent.ThreadSampler.Samples;
+
+/**
+ * The monitoring loop. It runs on its own daemon thread, one window at a time: sample the stacks until the power
+ * source ends the window, split that window's power between the branches seen, and append the results.
+ */
+final class Monitor {
+
+ private static final Logger logger = Logger.getLogger(Monitor.class.getName());
+
+ static final String THREAD_NAME = "Joular-Agent-Monitor";
+
+ private static final double LOW_COVERAGE_THRESHOLD = 0.5;
+ private static final double HIGH_OVERRUN_THRESHOLD = 0.25;
+ private static final long STOP_TIMEOUT_MS = 2000;
+
+ private final Callable powerSourceOpener;
+ private final long sampleRateNs;
+ private final List appPrefixes;
+ private final ResultWriter results;
+ private final OperatingSystemMXBean osBean;
+ private final ThreadSampler sampler;
+ private final Thread thread;
+
+ private volatile boolean running = true;
+ private boolean powerLost = false;
+ private boolean windowErrorLogged = false;
+ private boolean overrunWarned = false;
+ private boolean lowCoverageWarned = false;
+
+ /**
+ * Checks the JVM and opens the results files. The power source is opened later, on the monitor thread.
+ *
+ * @throws IllegalStateException if this JVM cannot report the CPU time of its threads and of itself
+ */
+ Monitor(Config config, Callable powerSourceOpener) throws IOException {
+ if (!(ManagementFactory.getThreadMXBean() instanceof ThreadMXBean threadBean)
+ || !threadBean.isThreadCpuTimeSupported()) {
+ throw new IllegalStateException("Unsupported JVM: it cannot report the CPU time of its threads.");
+ }
+ if (!(ManagementFactory.getOperatingSystemMXBean() instanceof OperatingSystemMXBean os)
+ || os.getProcessCpuTime() < 0) {
+ throw new IllegalStateException("Unsupported JVM: it cannot report its own CPU time.");
+ }
+ threadBean.setThreadCpuTimeEnabled(true);
+
+ this.powerSourceOpener = powerSourceOpener;
+ this.sampleRateNs = TimeUnit.MILLISECONDS.toNanos(config.sampleRateMs());
+ this.appPrefixes = config.appPrefixes();
+ this.osBean = os;
+ this.results = new ResultWriter(config.resultsDir());
+ this.thread = new Thread(this::run, THREAD_NAME);
+ this.thread.setDaemon(true);
+ this.sampler = new ThreadSampler(threadBean, thread.threadId());
+ }
+
+ void start() {
+ thread.start();
+ }
+
+ boolean isAlive() {
+ return thread.isAlive();
+ }
+
+ /** Stops the loop and waits a little for it to close the source and the files. */
+ void stop() {
+ running = false;
+ // Not an interrupt: it would close the results files if it came while they are written
+ LockSupport.unpark(thread);
+ try {
+ thread.join(STOP_TIMEOUT_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /** CPU times at one instant. */
+ private record CpuSnapshot(long nanoTime, long processCpuNs, Map threadCpuNs) {
+ }
+
+ private CpuSnapshot snapshot() {
+ // The monitor's own thread is taken out of the JVM's CPU time, as it is out of the threads'
+ return new CpuSnapshot(System.nanoTime(), osBean.getProcessCpuTime() - sampler.ownCpuTime(),
+ sampler.cpuTimes());
+ }
+
+ private void run() {
+ // Nothing may escape: the monitor must never disturb the application
+ try (PowerSource powerSource = powerSourceOpener.call()) {
+ monitor(powerSource);
+ } catch (IOException e) {
+ // The message says what to fix, a stack trace would only bury it
+ logger.log(Level.SEVERE, "Cannot measure the CPU power: " + e.getMessage()
+ + " Monitoring is off, the application carries on.");
+ } catch (Throwable t) {
+ logger.log(Level.SEVERE, "Monitoring stopped on an unexpected error, the application carries on.", t);
+ } finally {
+ results.close();
+ }
+ }
+
+ private void monitor(PowerSource powerSource) {
+ // getCpuLoad covers the time since its previous call, by anyone in this JVM, so the first one covers nothing
+ osBean.getCpuLoad();
+
+ // Each window starts where the previous one ended, so the time spent writing results is not lost
+ CpuSnapshot windowStart = snapshot();
+ while (running) {
+ try {
+ windowStart = runWindow(powerSource, windowStart);
+ windowErrorLogged = false;
+ } catch (RuntimeException e) {
+ logger.log(windowErrorLogged ? Level.FINE : Level.SEVERE, "Error in a monitoring window, continuing.", e);
+ windowErrorLogged = true;
+ windowStart = snapshot();
+ }
+ }
+ }
+
+ /** Runs one window and returns the snapshot it ended on, which the next window starts from. */
+ private CpuSnapshot runWindow(PowerSource powerSource, CpuSnapshot start) {
+ Samples samples = sampleWindow(powerSource, start.nanoTime());
+ if (!running) {
+ // Cut short by shutdown: a fraction of a window is not worth a row
+ return start;
+ }
+ CpuSnapshot end = snapshot();
+ // Read right after the snapshot, so the power and the CPU times cover the same window
+ double cpuPower = readPower(powerSource);
+ double systemLoad = osBean.getCpuLoad();
+ writeResults(samples, start, end, cpuPower, systemLoad);
+ return end;
+ }
+
+ /** Samples the stacks at the configured rate until the power source ends the window. */
+ private Samples sampleWindow(PowerSource powerSource, long windowStartNs) {
+ Samples samples = new Samples();
+
+ // Samples are due at fixed times, so the time a thread dump takes does not stretch the interval
+ long nextSampleNs = System.nanoTime();
+ while (running && !powerSource.windowOver(System.nanoTime() - windowStartNs)) {
+ sampler.sample(samples);
+
+ nextSampleNs += sampleRateNs;
+ long waitNs = nextSampleNs - System.nanoTime();
+ if (waitNs > 0) {
+ // Returns early on unpark, which is how stop() ends the wait
+ LockSupport.parkNanos(waitNs);
+ } else {
+ samples.missed++;
+ nextSampleNs = System.nanoTime();
+ }
+ }
+ return samples;
+ }
+
+ /** The CPU power over the window, or 0 when it could not be read: attributing nothing beats inventing energy. */
+ private double readPower(PowerSource powerSource) {
+ try {
+ double watts = powerSource.watts();
+ if (!Double.isFinite(watts) || watts < 0) {
+ throw new IOException("The power source reported " + watts + " W.");
+ }
+ if (powerLost) {
+ logger.log(Level.INFO, "The CPU power can be read again, energy is attributed again.");
+ powerLost = false;
+ }
+ return watts;
+ } catch (IOException e) {
+ if (!powerLost) {
+ logger.log(Level.WARNING, "Could not read the CPU power: " + e.getMessage()
+ + " No energy is attributed until it can be read again.");
+ powerLost = true;
+ }
+ return 0.0;
+ }
+ }
+
+ private void writeResults(Samples samples, CpuSnapshot start, CpuSnapshot end, double cpuPower,
+ double systemLoad) {
+ long elapsedNs = end.nanoTime() - start.nanoTime();
+ double intervalSeconds = elapsedNs / 1e9;
+ warnIfOverrunning(samples, intervalSeconds);
+
+ CpuUsage usage = PowerModel.cpuUsage(samples.branches.keySet(), start.threadCpuNs(), end.threadCpuNs());
+ warnIfLowCoverage(usage.coverage());
+
+ double processShare = PowerModel.processShare(end.processCpuNs() - start.processCpuNs(), elapsedNs,
+ Runtime.getRuntime().availableProcessors(), systemLoad);
+ Map threadPower = PowerModel.threadPower(usage, cpuPower * processShare);
+
+ Map allPower = PowerModel.branchPower(samples.branches, threadPower, Branch::name);
+ Map appPower = appPrefixes.isEmpty()
+ ? allPower
+ : PowerModel.branchPower(samples.branches, threadPower, branch -> branch.appName(appPrefixes));
+
+ results.write(allPower, appPower, System.currentTimeMillis(), intervalSeconds, usage.coverage());
+ }
+
+ private void warnIfOverrunning(Samples samples, double intervalSeconds) {
+ logger.log(Level.FINE, () -> "Window: " + samples.taken + " samples over " + intervalSeconds + " s, "
+ + samples.missed + " overran the interval");
+
+ boolean overrunning = samples.taken > 0 && (double) samples.missed / samples.taken >= HIGH_OVERRUN_THRESHOLD;
+ if (overrunning && !overrunWarned) {
+ long achievedMs = Math.round(intervalSeconds * 1000 / samples.taken);
+ logger.log(Level.WARNING, "The stack sampler cannot keep up with stack-monitoring-sample-rate="
+ + TimeUnit.NANOSECONDS.toMillis(sampleRateNs) + " ms: " + samples.taken + " samples were taken"
+ + " in the last window, about one every " + achievedMs + " ms. Dumping the stacks of this many"
+ + " threads costs more than the interval allows. Raise the sample rate, or accept coarser data.");
+ }
+ overrunWarned = overrunning;
+ }
+
+ private void warnIfLowCoverage(double coverage) {
+ logger.log(Level.FINE, () -> "Window coverage: " + coverage);
+
+ boolean low = coverage > 0 && coverage < LOW_COVERAGE_THRESHOLD;
+ if (low && !lowCoverageWarned) {
+ logger.log(Level.WARNING, "Only " + Math.round(coverage * 100) + "% of this JVM's CPU time belonged to"
+ + " threads that were sampled, so the power reported per method is a lower bound. Short-lived"
+ + " threads and virtual threads, which the JVM does not report here, are the usual causes;"
+ + " a sample rate too coarse for the number of threads is another.");
+ }
+ lowCoverageWarned = low;
+ }
+}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java
deleted file mode 100644
index af5a9b8..0000000
--- a/src/main/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandler.java
+++ /dev/null
@@ -1,362 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent;
-
-import java.lang.management.ThreadInfo;
-import java.lang.management.ThreadMXBean;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
-import java.util.function.Predicate;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import org.noureddine.joular.joularcodejava.agent.result.ResultWriter;
-import org.noureddine.joular.joularcodejava.agent.source.PowerSource;
-import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties;
-import com.sun.management.OperatingSystemMXBean;
-
-/**
- * Handles the monitoring loop: sampling at high frequency (10ms)
- * and computing branch-level attribution at lower frequency (1s by default).
- */
-public class MonitoringHandler implements Runnable {
-
- private static final Logger logger = Logger.getLogger(MonitoringHandler.class.getName());
-
- private static final long NANOS_PER_MILLI = 1_000_000L;
- private static final long NANOS_PER_SECOND = 1_000_000_000L;
- private static final long LOW_SAMPLE_RATE_WARNING_MS = 5L;
-
- private volatile boolean running = true;
- private volatile Thread monitoringThread;
- private final PowerSource powerSource;
- private final ResultWriter resultWriter;
- private final long sampleRateMs;
- private final List methodsFilteringPrefixes;
- private final long computationIntervalMs = 1000;
- private final long computationIntervalNs = computationIntervalMs * NANOS_PER_MILLI;
- private final ThreadMXBean threadBean;
- private final OperatingSystemMXBean osBean;
- private final CountDownLatch startupLatch = new CountDownLatch(1);
- private volatile boolean startupSuccessful = false;
- private volatile Throwable startupFailure;
-
- public MonitoringHandler(AgentProperties properties, PowerSource powerSource, ThreadMXBean threadBean,
- OperatingSystemMXBean osBean) {
- this.powerSource = powerSource;
- this.resultWriter = new ResultWriter(properties.getResultsPath());
- this.sampleRateMs = Math.max(1L, properties.getSampleRateMs());
- this.methodsFilteringPrefixes = properties.getMethodsFilteringPrefixes();
- this.threadBean = threadBean;
- this.osBean = osBean;
- if (this.sampleRateMs < LOW_SAMPLE_RATE_WARNING_MS) {
- logger.log(Level.WARNING,
- () -> "Configured stack-monitoring-sample-rate is " + this.sampleRateMs
- + " ms. Values below " + LOW_SAMPLE_RATE_WARNING_MS
- + " ms can noticeably increase monitoring overhead.");
- }
- }
-
- public void stop() {
- this.running = false;
- Thread t = this.monitoringThread;
- if (t != null) {
- t.interrupt();
- }
- }
-
- public void joinWithTimeout(long timeoutMs) {
- Thread t = this.monitoringThread;
- if (t != null) {
- try {
- t.join(timeoutMs);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- }
-
- /**
- * Waits up to {@code timeoutMs} for the startup attempt to settle (succeed or fail).
- *
- * Returns {@code true} as soon as the latch is released — which happens both on
- * successful initialization and on a startup failure. To distinguish, the
- * caller must consult {@link #isStartupSuccessful()} / {@link #getStartupFailure()}.
- *
- * @return {@code true} if startup completed (success or failure) within the timeout,
- * {@code false} if the timeout elapsed first (or the wait was interrupted).
- */
- public boolean awaitStartup(long timeoutMs) {
- try {
- return startupLatch.await(timeoutMs, TimeUnit.MILLISECONDS);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- return false;
- }
- }
-
- public boolean isStartupSuccessful() {
- return startupSuccessful;
- }
-
- public Throwable getStartupFailure() {
- return startupFailure;
- }
-
- @Override
- public void run() {
- logger.log(Level.INFO, "Monitoring loop started.");
- this.monitoringThread = Thread.currentThread();
- long monitoringThreadId = Thread.currentThread().threadId();
-
- try {
- try {
- resultWriter.verifyWritable();
- powerSource.initialize();
- startupSuccessful = true;
- } catch (Exception e) {
- startupFailure = e;
- logger.log(Level.SEVERE, "Failed to initialize monitoring loop", e);
- return;
- } finally {
- startupLatch.countDown();
- }
-
- while (running) {
- try {
- long windowStartNs = System.nanoTime();
- Map windowStartThreadCpuTime = snapshotThreadCpuTime(monitoringThreadId);
-
- // 1. Sample stack traces over the computation interval
- Map> allStatsSamples = new HashMap<>();
- Map> appStatsSamples = new HashMap<>();
- Map totalThreadSamples = new HashMap<>();
-
- while ((System.nanoTime() - windowStartNs) < computationIntervalNs && running) {
- // dumpAllThreads avoids the Map wrapping that
- // Thread.getAllStackTraces() builds on top of the same native call.
- ThreadInfo[] threadInfos = threadBean.dumpAllThreads(false, false);
- for (ThreadInfo info : threadInfos) {
- if (info == null) {
- continue;
- }
- long threadId = info.getThreadId();
- if (threadId == monitoringThreadId) {
- continue;
- }
- if (info.getThreadState() == Thread.State.RUNNABLE) {
- StackTraceElement[] stack = info.getStackTrace();
-
- totalThreadSamples.merge(threadId, 1, Integer::sum);
-
- // Group 1: All branches
- String branchAll = getBranchString(stack, s -> true);
- if (!branchAll.isEmpty()) {
- allStatsSamples.computeIfAbsent(threadId, k -> new HashMap<>()).merge(branchAll, 1,
- Integer::sum);
- }
-
- // Group 2: App branches
- String branchApp = getBranchString(stack, this::matchesMethodFilter);
- if (!branchApp.isEmpty()) {
- appStatsSamples.computeIfAbsent(threadId, k -> new HashMap<>()).merge(branchApp, 1,
- Integer::sum);
- }
- }
- }
- try {
- Thread.sleep(sampleRateMs);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- break;
- }
- }
-
- long windowEndNs = System.nanoTime();
- long nowMs = System.currentTimeMillis();
- long measuredIntervalNs = windowEndNs - windowStartNs;
- double measuredIntervalSeconds = (double) measuredIntervalNs / NANOS_PER_SECOND;
- if (measuredIntervalSeconds <= 0) {
- continue;
- }
- // Use the effective attribution window duration for per-interval energy.
- // Joular Core power at T is a trailing 1-second average, so this is an
- // approximation when the loop duration differs from 1 second.
- double energyIntervalSeconds = measuredIntervalSeconds;
-
- // 2. Calculate thread CPU deltas strictly within the current attribution
- // window.
- Map intervalThreadsCpuTime = new HashMap<>();
- double totalDeltaCpuTime = 0;
-
- // Use totalThreadSamples keys to identify active threads
- for (long threadId : totalThreadSamples.keySet()) {
- long currentCpuTime = threadBean.getThreadCpuTime(threadId);
- if (currentCpuTime < 0) {
- continue;
- }
-
- long startCpuTime = windowStartThreadCpuTime.getOrDefault(threadId, 0L);
- long delta = Math.max(0, currentCpuTime - startCpuTime);
-
- if (delta > 0) {
- intervalThreadsCpuTime.put(threadId, delta);
- totalDeltaCpuTime += delta;
- }
- }
-
- // 3. Get power and scale to process power
- double totalCpuPower = powerSource.getCurrentPower();
- double processShare = estimateProcessShare();
- double totalJHPower = 0.0;
- if (totalCpuPower > 0 && processShare > 0) {
- totalJHPower = totalCpuPower * processShare;
- }
- if (!Double.isFinite(totalJHPower) || totalJHPower < 0) {
- totalJHPower = 0.0;
- }
-
- Map threadPowerMap = calculateThreadPower(
- intervalThreadsCpuTime,
- totalDeltaCpuTime,
- totalJHPower);
-
- // 4. Save results
- attributeAndSave(allStatsSamples, totalThreadSamples, threadPowerMap, nowMs, energyIntervalSeconds,
- "methods-power-all.csv");
- attributeAndSave(appStatsSamples, totalThreadSamples, threadPowerMap, nowMs, energyIntervalSeconds,
- "methods-power-app.csv");
-
- } catch (Exception e) {
- logger.log(Level.SEVERE, "Error during monitoring iteration, continuing.", e);
- }
- }
- } catch (Exception e) {
- logger.log(Level.SEVERE, "Error in monitoring loop", e);
- } finally {
- startupLatch.countDown();
- try {
- powerSource.close();
- } catch (RuntimeException e) {
- logger.log(Level.FINE, "Error closing power source", e);
- }
- resultWriter.close();
- }
- }
-
- private double estimateProcessShare() {
- double processCpuLoad = sanitizeCpuLoad(osBean.getProcessCpuLoad());
- double systemCpuLoad = sanitizeCpuLoad(osBean.getCpuLoad());
- if (processCpuLoad <= 0 || systemCpuLoad <= 0) {
- return 0.0;
- }
- return sanitizeCpuLoad(processCpuLoad / systemCpuLoad);
- }
-
- private boolean matchesMethodFilter(String methodName) {
- if (methodsFilteringPrefixes.isEmpty()) {
- return true;
- }
- for (String prefix : methodsFilteringPrefixes) {
- if (methodName.startsWith(prefix)) {
- return true;
- }
- }
- return false;
- }
-
- private double sanitizeCpuLoad(double value) {
- if (!Double.isFinite(value) || value <= 0) {
- return 0;
- }
- return Math.min(1.0, value);
- }
-
- private String getBranchString(StackTraceElement[] stackTrace, Predicate filter) {
- StringBuilder sb = new StringBuilder();
- boolean first = true;
- for (int i = stackTrace.length - 1; i >= 0; i--) {
- StackTraceElement element = stackTrace[i];
- String methodName = element.getClassName() + "." + element.getMethodName();
- if (!filter.test(methodName)) {
- continue;
- }
- if (!first) {
- sb.append(";");
- }
- sb.append(methodName);
- first = false;
- }
- return sb.toString();
- }
-
- private Map snapshotThreadCpuTime(long excludedThreadId) {
- Map snapshot = new HashMap<>();
- for (long threadId : threadBean.getAllThreadIds()) {
- if (threadId == excludedThreadId) {
- continue;
- }
- long cpuTime = threadBean.getThreadCpuTime(threadId);
- if (cpuTime >= 0) {
- snapshot.put(threadId, cpuTime);
- }
- }
- return snapshot;
- }
-
- static Map calculateThreadPower(Map intervalThreadsCpuTime, double totalDeltaCpuTime,
- double totalJHPower) {
- Map threadPowerMap = new HashMap<>();
- if (totalJHPower <= 0 || !Double.isFinite(totalJHPower) || totalDeltaCpuTime <= 0) {
- return threadPowerMap;
- }
-
- for (Map.Entry entry : intervalThreadsCpuTime.entrySet()) {
- long threadId = entry.getKey();
- long threadCpuTime = entry.getValue();
- if (threadCpuTime > 0) {
- double threadPower = totalJHPower * ((double) threadCpuTime / totalDeltaCpuTime);
- threadPowerMap.put(threadId, threadPower);
- }
- }
- return threadPowerMap;
- }
-
- private void attributeAndSave(Map> stats, Map totalThreadSamples,
- Map threadPowerMap, long timestampMs, double intervalSeconds, String fileName) {
- Map methodPowerTotals = new HashMap<>();
-
- for (Map.Entry> threadEntry : stats.entrySet()) {
- long threadId = threadEntry.getKey();
- Double threadPower = threadPowerMap.get(threadId);
- if (threadPower == null || threadPower <= 0)
- continue;
-
- Integer totalSamples = totalThreadSamples.get(threadId);
- if (totalSamples == null || totalSamples <= 0)
- continue;
-
- Map methodCounts = threadEntry.getValue();
-
- for (var methodEntry : methodCounts.entrySet()) {
- double methodPower = (methodEntry.getValue().doubleValue() / totalSamples) * threadPower;
- methodPowerTotals.merge(methodEntry.getKey(), methodPower, Double::sum);
- }
- }
-
- if (!methodPowerTotals.isEmpty()) {
- resultWriter.writeRuntimeMethods(methodPowerTotals, timestampMs, intervalSeconds, fileName);
- }
- }
-}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBuffer.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBuffer.java
new file mode 100644
index 0000000..eb510a6
--- /dev/null
+++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBuffer.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import java.io.IOException;
+import java.lang.invoke.VarHandle;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.FileChannel;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Reads CPU power from the shared memory ring buffer PowerJoular writes with {@code -r}.
+ * PowerJoular runs as a process of its own, so the privileges needed to measure the hardware stay out of the Java application.
+ *
+ * The area is a plain file on every OS, mapped read-only here. Layout, in the machine's byte order:
+ *
+ *
+ * Offset 0 : u64 head, the number of cycles written so far
+ * Offset 8 : entry[0], then entry[1] ... entry[4], 48 bytes each
+ * Each entry:
+ * +0 u64 timestamp (Unix seconds)
+ * +8 f64 cpu_power (watts) <- read here
+ * +16 f64 gpu_power
+ * +24 f64 total_power
+ * +32 f64 cpu_usage
+ * +40 f64 pid_app_power
+ * Total: 8 + 5 * 48 = 248 bytes
+ *
+ *
+ * PowerJoular writes entry {@code head mod 5}, then increments {@code head}, once a second.
+ * Windows end when {@code head} moves, so the stack samples of a window describe the same second as the power charged to it.
+ *
+ *
When PowerJoular restarts, it deletes the file and creates a new one. The old one stays mapped here and stops
+ * moving, so a window without a new entry drops the mapping, and the next read maps whatever file is at the path by
+ * then. Not on Windows, which refuses to delete a mapped file: there PowerJoular has to be started before the
+ * application, and not restarted while it runs. A file left behind by a PowerJoular that is gone is told by the age
+ * of its newest entry.
+ */
+final class PowerJoularRingBuffer implements PowerSource {
+
+ private static final Logger logger = Logger.getLogger(PowerJoularRingBuffer.class.getName());
+
+ static final int ENTRY_SIZE = 48;
+ static final int ENTRY_COUNT = 5;
+ static final int FILE_SIZE = 8 + ENTRY_COUNT * ENTRY_SIZE;
+ private static final int CPU_POWER_OFFSET = 8;
+
+ /** How long a window waits for PowerJoular's next cycle. */
+ static final long MAX_WINDOW_NS = 2 * ONE_SECOND_NS;
+
+ /** PowerJoular writes once a second, so an entry older than this means it has stopped. */
+ static final long MAX_AGE_SECONDS = 5;
+
+ private final Path path;
+
+ /** The mapped area, or null while there is none. */
+ private ByteBuffer buffer;
+
+ /** The head when the current window began. */
+ private long windowStartHead;
+
+ private PowerJoularRingBuffer(Path path) {
+ this.path = path;
+ }
+
+ /** Opens the source even if the ring buffer is not there yet: PowerJoular may start after the application. */
+ static PowerJoularRingBuffer open(Path path) {
+ PowerJoularRingBuffer ring = new PowerJoularRingBuffer(path);
+ try {
+ ring.attach();
+ ring.windowStartHead = ring.buffer.getLong(0);
+ } catch (IOException ignored) {
+ // Reported by the first reading, which tries again
+ }
+ logger.log(Level.INFO, "Reading CPU power from the PowerJoular ring buffer " + path);
+ return ring;
+ }
+
+ @Override
+ public boolean windowOver(long elapsedNs) {
+ if (buffer == null) {
+ return elapsedNs >= ONE_SECOND_NS;
+ }
+ return buffer.getLong(0) != windowStartHead || elapsedNs >= MAX_WINDOW_NS;
+ }
+
+ @Override
+ public double watts() throws IOException {
+ if (buffer == null) {
+ attach();
+ }
+
+ long head = buffer.getLong(0);
+ long previousHead = windowStartHead;
+ windowStartHead = head;
+ if (head <= 0) {
+ throw new IOException("PowerJoular has not published a measurement in " + path + " yet.");
+ }
+ if (head == previousHead) {
+ // Nothing measured this window. PowerJoular may have stopped, or restarted on a new file, which the next reading maps.
+ buffer = null;
+ throw new IOException("PowerJoular published no measurement in " + path + " for "
+ + MAX_WINDOW_NS / ONE_SECOND_NS + " s. Is it still running with -r?");
+ }
+
+ // The entry is complete once the counter past it is published, and PowerJoular only writes that slot again four cycles later.
+ // The counter is still read again after the entry: if it moved that far, this thread was paused and the entry may have been rewritten under it.
+ // The fence pairs with PowerJoular's barrier before it publishes the counter; without it an ARM CPU may reorder the reads.
+ VarHandle.acquireFence();
+
+ int offset = 8 + (int) ((head - 1) % ENTRY_COUNT) * ENTRY_SIZE;
+ long timestamp = buffer.getLong(offset);
+ double power = buffer.getDouble(offset + CPU_POWER_OFFSET);
+
+ VarHandle.acquireFence();
+ if (buffer.getLong(0) != head) {
+ throw new IOException("PowerJoular was writing to " + path + " while it was read.");
+ }
+
+ // A timestamp in the future is accepted: the clock may have stepped
+ long ageSeconds = System.currentTimeMillis() / 1000L - timestamp;
+ if (ageSeconds > MAX_AGE_SECONDS) {
+ buffer = null;
+ throw new IOException("The latest measurement in " + path + " is " + ageSeconds
+ + " s old. Is PowerJoular still running with -r?");
+ }
+ // A negative or NaN value is no power. PowerJoular writes 0 here when it has no CPU source
+ if (!Double.isFinite(power) || power < 0) {
+ throw new IOException("PowerJoular could not measure the CPU power (it wrote " + power + ").");
+ }
+ return power;
+ }
+
+ @Override
+ public void close() {
+ // The mapping is released by the garbage collector
+ buffer = null;
+ }
+
+ private void attach() throws IOException {
+ // Not following links, and only a plain file: the default path is in a directory every user can write to,
+ // and a pipe planted there would block the open until someone writes to it
+ if (Files.exists(path, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
+ throw new IOException(path + " is not a regular file.");
+ }
+ try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
+ // Mapping past the end of the file would fault on access rather than fail here
+ if (channel.size() < FILE_SIZE) {
+ throw new IOException(path + " is too short to be a PowerJoular ring buffer, or is still being created.");
+ }
+ buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, FILE_SIZE).order(ByteOrder.nativeOrder());
+ } catch (NoSuchFileException e) {
+ throw new IOException("There is no PowerJoular ring buffer at " + path + ". Is PowerJoular running with -r?", e);
+ }
+ }
+}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerModel.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerModel.java
new file mode 100644
index 0000000..6239053
--- /dev/null
+++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerModel.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+/**
+ * Splits the CPU power drawn over a window between the call branches seen on the JVM's threads.
+ *
+ *
+ * processPower = cpuPower x processShare
+ * threadPower(t) = processPower x cpuTime(t) / totalCpuTime
+ * branchPower(b) = threadPower(t) x samples(t, b) / samples(t)
+ *
+ *
+ * Threads are weighted by CPU time rather than by sample count, so a thread blocked in native I/O (which Java
+ * still reports as {@code RUNNABLE}) is not charged for waiting.
+ *
+ *
{@code totalCpuTime} includes threads that were never sampled. Their share is left unattributed rather than
+ * spread over the threads that were seen; {@link CpuUsage#coverage()} says how much that is.
+ *
+ *
The JVM's native threads (GC, JIT) are in {@code processPower} but in no Java thread's CPU time, so their
+ * share is spread over the Java threads in proportion to theirs.
+ *
+ *
Blind spots: threads that end inside a window (their CPU time is no longer reported), virtual threads
+ * (not in thread dumps), and whether a sampled thread was actually on a CPU at that instant.
+ */
+final class PowerModel {
+
+ private PowerModel() {
+ }
+
+ /**
+ * The JVM's share of the machine's CPU power: its own CPU load over the machine's.
+ *
+ * @param processCpuNs CPU time the whole JVM used over the window
+ * @param elapsedNs the window's length
+ * @param cpus the processors available to the JVM
+ * @param systemLoad the machine's CPU load over the window, from 0 to 1; 0, negative or NaN when unknown
+ * @return a share from 0 to 1
+ */
+ static double processShare(long processCpuNs, long elapsedNs, int cpus, double systemLoad) {
+ if (processCpuNs <= 0 || elapsedNs <= 0 || cpus <= 0) {
+ return 0.0;
+ }
+ double processLoad = Math.min(1.0, (double) processCpuNs / ((double) elapsedNs * cpus));
+ if (!(systemLoad > 0)) {
+ // Take the machine as fully loaded: this under-states the JVM's share, never over-states it
+ return processLoad;
+ }
+ return Math.min(1.0, processLoad / Math.min(1.0, systemLoad));
+ }
+
+ /**
+ * CPU time used over a window.
+ *
+ * @param sampledThreadsNs CPU time of each thread that was sampled at least once
+ * @param totalNs CPU time of all threads, sampled or not
+ * @param coverage the sampled share of {@code totalNs}, from 0 to 1
+ */
+ record CpuUsage(Map sampledThreadsNs, long totalNs, double coverage) {
+ }
+
+ /** Compares two CPU-time snapshots, keyed by thread id. */
+ static CpuUsage cpuUsage(Set sampledThreads, Map start, Map end) {
+ Map sampledThreadsNs = new HashMap<>();
+ long totalNs = 0;
+ long sampledNs = 0;
+
+ for (Map.Entry entry : end.entrySet()) {
+ long threadId = entry.getKey();
+ // A thread missing from the start snapshot began inside the window (thread ids are never reused)
+ long usedNs = entry.getValue() - start.getOrDefault(threadId, 0L);
+ if (usedNs <= 0) {
+ continue;
+ }
+ totalNs += usedNs;
+ if (sampledThreads.contains(threadId)) {
+ sampledNs += usedNs;
+ sampledThreadsNs.put(threadId, usedNs);
+ }
+ }
+
+ double coverage = totalNs > 0 ? (double) sampledNs / totalNs : 0.0;
+ return new CpuUsage(sampledThreadsNs, totalNs, coverage);
+ }
+
+ /** Each sampled thread's share of the process power, in watts. */
+ static Map threadPower(CpuUsage usage, double processPower) {
+ Map threadPower = new HashMap<>();
+ if (processPower <= 0 || usage.totalNs() <= 0) {
+ return threadPower;
+ }
+ usage.sampledThreadsNs().forEach((threadId, usedNs) ->
+ threadPower.put(threadId, processPower * usedNs / usage.totalNs()));
+ return threadPower;
+ }
+
+ /**
+ * Spreads each thread's power over the branches seen on it, in proportion to how often each was seen.
+ *
+ * @param samples per thread, how many times each branch was seen
+ * @param threadPower per thread, its power in watts
+ * @param naming the name a branch is reported under; an empty name leaves its share unattributed
+ * @return the power of each branch name, summed over threads
+ */
+ static Map branchPower(Map> samples, Map threadPower,
+ Function naming) {
+ Map branchPower = new HashMap<>();
+ samples.forEach((threadId, branches) -> {
+ Double power = threadPower.get(threadId);
+ if (power == null) {
+ return;
+ }
+ int threadSamples = branches.values().stream().mapToInt(Integer::intValue).sum();
+ branches.forEach((branch, count) -> {
+ String name = naming.apply(branch);
+ if (!name.isEmpty()) {
+ branchPower.merge(name, power * count / threadSamples, Double::sum);
+ }
+ });
+ });
+ return branchPower;
+ }
+}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerSource.java
new file mode 100644
index 0000000..d573ad9
--- /dev/null
+++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/PowerSource.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import java.io.IOException;
+
+/**
+ * Where the agent gets the machine's CPU power from.
+ * A source is opened, read and closed on the monitor thread only.
+ *
+ * The monitor calls {@link #watts()} once per window, as the window ends, so a window always runs from one call to the next.
+ */
+interface PowerSource extends AutoCloseable {
+
+ /** Length of a window, for a source with no cadence of its own. */
+ long ONE_SECOND_NS = 1_000_000_000L;
+
+ /**
+ * The CPU's average power since the previous call, or since the source was opened.
+ *
+ * @return watts, finite and {@code >= 0}
+ * @throws IOException when nothing usable could be read, in which case the window gets no energy
+ */
+ double watts() throws IOException;
+
+ /**
+ * Whether the window should end now. Called on every stack sample, so it must be cheap.
+ *
+ * @param elapsedNs time since the window began
+ */
+ default boolean windowOver(long elapsedNs) {
+ return elapsedNs >= ONE_SECOND_NS;
+ }
+
+ @Override
+ void close();
+
+ /**
+ * Opens the source named by {@code power-source-type}. {@code auto} reads RAPL directly on Linux, and the
+ * PowerJoular ring buffer everywhere else, including Linux machines without RAPL such as the Raspberry Pi.
+ */
+ static PowerSource open(Config config) throws IOException {
+ return switch (config.powerSourceType()) {
+ case "auto" -> LinuxRapl.isPresent()
+ ? LinuxRapl.open()
+ : PowerJoularRingBuffer.open(config.ringBufferPath());
+ case "rapl" -> LinuxRapl.open();
+ case "ringbuffer" -> PowerJoularRingBuffer.open(config.ringBufferPath());
+ case "vm" -> VmPowerFile.open(config.vmPowerFile(), config.vmPowerFormat());
+ default -> throw new IOException("Unknown power-source-type '" + config.powerSourceType() + "'. Use auto, rapl, ringbuffer or vm.");
+ };
+ }
+}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/ResultWriter.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/ResultWriter.java
new file mode 100644
index 0000000..16a4ea3
--- /dev/null
+++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/ResultWriter.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Appends each window's power per branch to the two CSV files of the results directory: every branch, and the branches of the application only.
+ */
+final class ResultWriter implements AutoCloseable {
+
+ private static final Logger logger = Logger.getLogger(ResultWriter.class.getName());
+
+ static final String ALL_METHODS_FILE = "methods-power-all.csv";
+ static final String APP_METHODS_FILE = "methods-power-app.csv";
+ static final String CSV_HEADER = "timestamp,branch,power_watts,energy_joules,interval_seconds,coverage";
+
+ private final Path allFile;
+ private final Path appFile;
+ private final Map writers = new HashMap<>();
+ private boolean writeErrorLogged = false;
+
+ /** Creates the results directory and opens both files, so a path that cannot be written to fails at startup. */
+ ResultWriter(Path resultsDir) throws IOException {
+ Files.createDirectories(resultsDir);
+ allFile = resultsDir.resolve(ALL_METHODS_FILE);
+ appFile = resultsDir.resolve(APP_METHODS_FILE);
+ try {
+ writerFor(allFile);
+ writerFor(appFile);
+ } catch (IOException e) {
+ close();
+ throw e;
+ }
+ }
+
+ /**
+ * Appends one row per branch with a positive power, to each file.
+ *
+ * @param coverage the share of the JVM's CPU time that belonged to sampled threads; below 1.0 the rows are a lower bound
+ */
+ void write(Map allPower, Map appPower, long timestampMs, double intervalSeconds,
+ double coverage) {
+ try {
+ append(allFile, allPower, timestampMs, intervalSeconds, coverage);
+ append(appFile, appPower, timestampMs, intervalSeconds, coverage);
+ writeErrorLogged = false;
+ } catch (IOException e) {
+ // Drop the writers so the next window opens the files again
+ close();
+ if (!writeErrorLogged) {
+ logger.log(Level.SEVERE, "Could not write results to " + allFile.getParent(), e);
+ writeErrorLogged = true;
+ }
+ }
+ }
+
+ /** Flushes and closes both files. Safe to call more than once. */
+ @Override
+ public void close() {
+ for (BufferedWriter writer : writers.values()) {
+ try {
+ writer.close();
+ } catch (IOException e) {
+ logger.log(Level.FINE, "Could not close a results file", e);
+ }
+ }
+ writers.clear();
+ }
+
+ private void append(Path file, Map branchPower, long timestampMs, double intervalSeconds,
+ double coverage) throws IOException {
+ if (branchPower.isEmpty()) {
+ return;
+ }
+ BufferedWriter writer = writerFor(file);
+ for (Map.Entry entry : branchPower.entrySet()) {
+ double power = entry.getValue();
+ if (power > 0) {
+ writer.write(String.format(Locale.ROOT, "%d,%s,%.9f,%.9f,%.9f,%.4f%n", timestampMs,
+ csvEscape(entry.getKey()), power, power * intervalSeconds, intervalSeconds, coverage));
+ }
+ }
+ writer.flush();
+ }
+
+ private BufferedWriter writerFor(Path file) throws IOException {
+ BufferedWriter writer = writers.get(file);
+ if (writer == null) {
+ // Not following links: the agent may run as root, and a link planted in a shared results directory
+ // would otherwise have it append to any file on the system
+ writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8, StandardOpenOption.CREATE,
+ StandardOpenOption.APPEND, LinkOption.NOFOLLOW_LINKS);
+ writers.put(file, writer);
+ if (Files.size(file) == 0) {
+ writer.write(CSV_HEADER);
+ writer.newLine();
+ writer.flush();
+ }
+ }
+ return writer;
+ }
+
+ static String csvEscape(String value) {
+ if (value.contains(",") || value.contains("\"") || value.contains("\n") || value.contains("\r")) {
+ return "\"" + value.replace("\"", "\"\"") + "\"";
+ }
+ return value;
+ }
+}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/ThreadSampler.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/ThreadSampler.java
new file mode 100644
index 0000000..76dbf9c
--- /dev/null
+++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/ThreadSampler.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import com.sun.management.ThreadMXBean;
+import java.lang.management.ThreadInfo;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Reads the JVM's threads: which branch each running thread is on, and how much CPU time each has used.
+ * The agent's own thread is left out of both.
+ */
+final class ThreadSampler {
+
+ private final ThreadMXBean threadBean;
+ private final long ownThreadId;
+
+ ThreadSampler(ThreadMXBean threadBean, long ownThreadId) {
+ this.threadBean = threadBean;
+ this.ownThreadId = ownThreadId;
+ }
+
+ /** What was seen during one window. */
+ static final class Samples {
+ /** Per thread, how many times it was caught on each branch. */
+ final Map> branches = new HashMap<>();
+ int taken = 0;
+ int missed = 0;
+ }
+
+ /** Records the branch each running thread is on. */
+ void sample(Samples samples) {
+ samples.taken++;
+ for (ThreadInfo info : threadBean.dumpAllThreads(false, false)) {
+ if (info == null || info.getThreadId() == ownThreadId || info.getThreadState() != Thread.State.RUNNABLE) {
+ continue;
+ }
+ StackTraceElement[] stack = info.getStackTrace();
+ if (stack.length > 0) {
+ samples.branches.computeIfAbsent(info.getThreadId(), id -> new HashMap<>())
+ .merge(Branch.of(stack), 1, Integer::sum);
+ }
+ }
+ }
+
+ /** The cumulative CPU time of every live thread, in nanoseconds. */
+ Map cpuTimes() {
+ long[] ids = threadBean.getAllThreadIds();
+ long[] times = threadBean.getThreadCpuTime(ids);
+ Map cpuTimes = new HashMap<>();
+ for (int i = 0; i < ids.length; i++) {
+ // -1 means the thread ended since the ids were read
+ if (ids[i] != ownThreadId && times[i] >= 0) {
+ cpuTimes.put(ids[i], times[i]);
+ }
+ }
+ return cpuTimes;
+ }
+
+ /** The cumulative CPU time of the agent's own thread, in nanoseconds. */
+ long ownCpuTime() {
+ return threadBean.getThreadCpuTime(ownThreadId);
+ }
+}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/VmPowerFile.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/VmPowerFile.java
new file mode 100644
index 0000000..85215dd
--- /dev/null
+++ b/src/main/java/org/noureddine/joular/joularcodejava/agent/VmPowerFile.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Reads the power of this virtual machine from a file its host writes, shared with the guest (virtiofs, 9p, a shared folder).
+ * The CPU of a virtual machine cannot be measured from inside it, but the host can measure the process the virtual machine runs as.
+ *
+ * Only the first line is read, in one of the two formats:
+ *
+ * - {@code powerjoular}: the file PowerJoular rewrites every second with {@code -o} for a monitored process,
+ * one row of {@code timestamp,cpu_usage,cpu_power} and no header
+ * - {@code watts}: the power alone, in watts, written by anything. It is used as it is.
+ *
+ *
+ * The host writes automatically one-second cycle, which windows cannot follow through a shared folder, so a window may see the row the previous one saw, or catch the file while it is rewritten.
+ * A row keeps covering for up to {@link #MAX_REUSES} more windows; after that, the host is taken to have stopped.
+ * Its own timestamp tells, so the clocks of the host and the guest are never compared.
+ */
+final class VmPowerFile implements PowerSource {
+
+ private static final Logger logger = Logger.getLogger(VmPowerFile.class.getName());
+
+ /** A power value never takes that many characters, so a longer line is not one. */
+ private static final int MAX_LINE_LENGTH = 256;
+
+ /** How many more windows the last row covers when the next one cannot be read. */
+ static final int MAX_REUSES = 2;
+
+ private final Path file;
+ private final boolean powerJoularFormat;
+
+ /** The host's timestamp of the last row read, or -1 before the first one. */
+ private long lastTimestamp = -1;
+ private double lastPower;
+ private int reuses = 0;
+
+ private VmPowerFile(Path file, boolean powerJoularFormat) {
+ this.file = file;
+ this.powerJoularFormat = powerJoularFormat;
+ }
+
+ /** Opens the source even if the file is not there yet: the host may start writing it after the application. */
+ static VmPowerFile open(Path file, String format) throws IOException {
+ if (file == null) {
+ throw new IOException("power-source-type=vm needs vm-power-file, the file the host writes the power of"
+ + " this virtual machine to.");
+ }
+ if (!format.equals("powerjoular") && !format.equals("watts")) {
+ throw new IOException("Unknown vm-power-format '" + format + "'. Use powerjoular or watts.");
+ }
+ logger.log(Level.INFO, "Reading the power of this virtual machine from " + file + " (" + format + " format)");
+ return new VmPowerFile(file, format.equals("powerjoular"));
+ }
+
+ @Override
+ public double watts() throws IOException {
+ if (!powerJoularFormat) {
+ return parsePower(firstLine(false));
+ }
+
+ IOException problem;
+ try {
+ Row row = readRow();
+ if (row.timestamp() != lastTimestamp) {
+ lastTimestamp = row.timestamp();
+ lastPower = row.power();
+ reuses = 0;
+ return lastPower;
+ }
+ problem = new IOException("The host has not updated " + file + " for " + (MAX_REUSES + 1)
+ + " windows. Is PowerJoular still running on the host?");
+ } catch (IOException e) {
+ problem = e;
+ }
+
+ // The last row covers a window that saw no new one, for a little while
+ if (lastTimestamp < 0 || reuses == MAX_REUSES) {
+ throw problem;
+ }
+ reuses++;
+ return lastPower;
+ }
+
+ @Override
+ public void close() {
+ }
+
+ /** One row of the file PowerJoular writes for a process: the host's Unix time, and the power in watts. */
+ private record Row(long timestamp, double power) {
+ }
+
+ private Row readRow() throws IOException {
+ String[] columns = firstLine(true).split(",", -1);
+ if (columns.length == 5) {
+ throw new IOException(file + " holds the power of the whole host. Point vm-power-file at the file"
+ + " PowerJoular writes for the process of this virtual machine: with -p -o , that is"
+ + " -.csv.");
+ }
+ if (columns.length != 3) {
+ throw new IOException(file + " does not hold the row PowerJoular writes with -o for a process"
+ + " (timestamp,cpu_usage,cpu_power).");
+ }
+ return new Row(parseTimestamp(columns[0]), parsePower(columns[2]));
+ }
+
+ /**
+ * The first line of the file, trimmed.
+ *
+ * @param complete whether the line must end with a newline: a row without one may have been cut short while the
+ * host was writing it, and a cut number still parses
+ */
+ private String firstLine(boolean complete) throws IOException {
+ // A pipe or a device would block the open until someone writes to it; a missing file is reported below
+ if (Files.exists(file) && !Files.isRegularFile(file)) {
+ throw new IOException(file + " is not a regular file.");
+ }
+ byte[] start;
+ try (InputStream input = Files.newInputStream(file)) {
+ start = input.readNBytes(MAX_LINE_LENGTH + 1);
+ } catch (NoSuchFileException e) {
+ throw new IOException("There is no " + file + ". Is it shared with this virtual machine, and is the host"
+ + " writing it?", e);
+ }
+ String text = new String(start, StandardCharsets.UTF_8);
+ int end = text.indexOf('\n');
+ if (end < 0 && (complete || start.length > MAX_LINE_LENGTH)) {
+ throw new IOException("The first line of " + file + " is incomplete or too long.");
+ }
+ return (end < 0 ? text : text.substring(0, end)).trim();
+ }
+
+ private long parseTimestamp(String text) throws IOException {
+ try {
+ return Long.parseLong(text.trim());
+ } catch (NumberFormatException e) {
+ throw new IOException("The first column of " + file + " is not a Unix timestamp. On the host, write the"
+ + " file with -o, not -f: -f starts it with a header.", e);
+ }
+ }
+
+ private double parsePower(String text) throws IOException {
+ double power;
+ try {
+ power = Double.parseDouble(text.trim());
+ } catch (NumberFormatException e) {
+ throw new IOException(file + " does not hold a power in watts where one is expected.", e);
+ }
+ // PowerJoular writes -1 for a process it could not measure
+ if (!Double.isFinite(power) || power < 0) {
+ throw new IOException("The host could not measure the power of this virtual machine (" + file
+ + " holds " + power + ").");
+ }
+ return power;
+ }
+}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriter.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriter.java
deleted file mode 100644
index b912901..0000000
--- a/src/main/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriter.java
+++ /dev/null
@@ -1,164 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.result;
-
-import java.io.BufferedWriter;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardOpenOption;
-import java.util.HashMap;
-import java.util.Locale;
-import java.util.Map;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-public class ResultWriter {
-
- private static final Logger logger = Logger.getLogger(ResultWriter.class.getName());
- private static final String CSV_HEADER = "timestamp,branch,power_watts,energy_joules,interval_seconds";
-
- private final Path resultsDir;
- private final Map openWriters = new HashMap<>();
- private boolean anyWriteErrorLogged = false;
-
- public ResultWriter(String resultsPath) {
- this.resultsDir = Path.of(resultsPath);
- }
-
- /**
- * Verifies that the results directory exists (creating it if necessary) and is writable.
- * Throws IOException if the directory cannot be created or is not writable, so the
- * agent fails fast at startup instead of silently dropping results.
- */
- public void verifyWritable() throws IOException {
- Files.createDirectories(resultsDir);
- if (!Files.isDirectory(resultsDir)) {
- throw new IOException("Results path is not a directory: " + resultsDir);
- }
- Path probe = resultsDir.resolve(".joular-write-probe");
- try {
- Files.write(probe, new byte[0],
- StandardOpenOption.CREATE,
- StandardOpenOption.WRITE,
- StandardOpenOption.TRUNCATE_EXISTING);
- } finally {
- try {
- Files.deleteIfExists(probe);
- } catch (IOException ignored) {
- // probe cleanup is best-effort
- }
- }
- }
-
- public void writeRuntimeMethods(
- Map methodPower,
- long timestamp,
- double intervalSeconds,
- String fileName) {
- Path path = resultsDir.resolve(fileName);
- logger.log(Level.FINE, () -> "Writing " + methodPower.size() + " methods to " + path);
-
- try {
- BufferedWriter writer = openWriters.get(fileName);
- if (writer == null) {
- boolean needsHeader = !Files.exists(path) || Files.size(path) == 0L;
- writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8,
- StandardOpenOption.CREATE, StandardOpenOption.APPEND);
- if (needsHeader) {
- writer.write(CSV_HEADER);
- writer.newLine();
- }
- openWriters.put(fileName, writer);
- }
-
- for (Map.Entry entry : methodPower.entrySet()) {
- double powerWatts = entry.getValue();
- if (powerWatts > 0) {
- double energyJoules = powerWatts * intervalSeconds;
- writer.write(String.format(
- Locale.US,
- "%d,%s,%.9f,%.9f,%.9f",
- timestamp,
- csvEscape(entry.getKey()),
- powerWatts,
- energyJoules,
- intervalSeconds));
- writer.newLine();
- }
- }
- writer.flush();
- anyWriteErrorLogged = false;
- } catch (IOException e) {
- // If the writer is in a bad state, drop it so the next call retries from scratch.
- BufferedWriter stale = openWriters.remove(fileName);
- if (stale != null) {
- try {
- stale.close();
- } catch (IOException ignored) {
- // best-effort
- }
- }
- if (!anyWriteErrorLogged) {
- logger.log(Level.SEVERE, "Error writing runtime methods to " + path, e);
- anyWriteErrorLogged = true;
- } else {
- logger.log(Level.FINE, () -> "Error writing runtime methods to " + path + ": " + e.getMessage());
- }
- }
- }
-
- /**
- * Flush and close any open writers. Safe to call multiple times.
- */
- public void close() {
- for (Map.Entry entry : openWriters.entrySet()) {
- try {
- entry.getValue().flush();
- entry.getValue().close();
- } catch (IOException e) {
- logger.log(Level.FINE, () -> "Error closing writer for " + entry.getKey() + ": " + e.getMessage());
- }
- }
- openWriters.clear();
- }
-
- static String csvEscape(String value) {
- if (value == null) {
- return "";
- }
- boolean needsQuoting = false;
- for (int i = 0; i < value.length(); i++) {
- char c = value.charAt(i);
- if (c == ',' || c == '"' || c == '\n' || c == '\r') {
- needsQuoting = true;
- break;
- }
- }
- if (!needsQuoting) {
- return value;
- }
- StringBuilder sb = new StringBuilder(value.length() + 2);
- sb.append('"');
- for (int i = 0; i < value.length(); i++) {
- char c = value.charAt(i);
- if (c == '"') {
- sb.append('"').append('"');
- } else {
- sb.append(c);
- }
- }
- sb.append('"');
- return sb.toString();
- }
-}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSource.java
deleted file mode 100644
index 65499b7..0000000
--- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSource.java
+++ /dev/null
@@ -1,166 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.source;
-
-import java.io.IOException;
-import java.io.RandomAccessFile;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-public class JoularCoreCSVSource implements PowerSource {
-
- private static final Logger logger = Logger.getLogger(
- JoularCoreCSVSource.class.getName()
- );
- private static final int TAIL_READ_SIZE = 8192;
- private final Path csvPath;
- private double lastKnownPower = 0.0;
- private long lastKnownLength = -1L;
- private long lastKnownModified = -1L;
- private boolean missingFileWarningLogged = false;
-
- public JoularCoreCSVSource(String csvPath) {
- this.csvPath = Path.of(csvPath);
- }
-
- @Override
- public void initialize() throws Exception {
- logger.log(Level.INFO, () -> "Initializing CSV source: " + csvPath);
- }
-
- @Override
- public double getCurrentPower() {
- if (!Files.exists(csvPath) || !Files.isRegularFile(csvPath)) {
- if (!missingFileWarningLogged) {
- logger.log(
- Level.WARNING,
- () -> "Could not read power data from CSV: " + csvPath
- + ". Ensure Joular Core is running and generating this file."
- );
- missingFileWarningLogged = true;
- }
- return 0;
- }
-
- missingFileWarningLogged = false;
-
- try {
- long currentLength = Files.size(csvPath);
- long currentModified = Files.getLastModifiedTime(csvPath).toMillis();
- if (currentLength == lastKnownLength && currentModified == lastKnownModified) {
- return lastKnownPower;
- }
-
- String lastLine = readLastNonEmptyLine();
- if (lastLine == null) {
- updateFileMetadata(currentLength, currentModified);
- return lastKnownPower;
- }
-
- Double parsedPower = parseCpuPower(lastLine);
- if (parsedPower != null) {
- lastKnownPower = parsedPower;
- }
- updateFileMetadata(currentLength, currentModified);
- } catch (IOException e) {
- logger.log(Level.FINE, () -> "Could not read latest power row from CSV: " + csvPath);
- }
-
- return lastKnownPower;
- }
-
- @Override
- public void close() {}
-
- private String readLastNonEmptyLine() throws IOException {
- try (RandomAccessFile file = new RandomAccessFile(csvPath.toFile(), "r")) {
- long length = file.length();
- if (length <= 0) {
- return null;
- }
-
- int bytesToRead = (int) Math.min(TAIL_READ_SIZE, length);
- long start = length - bytesToRead;
- file.seek(start);
-
- byte[] data = new byte[bytesToRead];
- file.readFully(data);
-
- String chunk = new String(data, StandardCharsets.UTF_8);
- String[] lines = chunk.split("\\r?\\n");
- // If we did not start reading at the beginning of the file, the first slice in
- // `lines` is almost certainly a partial line (we landed mid-record). Skipping
- // it avoids parsing a truncated CSV row whose 2nd field may be a wrong-but-valid
- // numeric prefix (e.g., "2.3" of "2.345").
- int firstSafeIndex = (start > 0) ? 1 : 0;
- for (int i = lines.length - 1; i >= firstSafeIndex; i--) {
- String line = lines[i].trim();
- if (line.isEmpty()) {
- continue;
- }
- if (isHeaderLine(line)) {
- continue;
- }
- return line;
- }
- return null;
- }
- }
-
- static Double parseCpuPower(String csvLine) {
- String[] parts = csvLine.split(",");
- if (parts.length <= 1) {
- return null;
- }
-
- String cpuPowerText = parts[1].trim();
- if (cpuPowerText.isEmpty()) {
- return null;
- }
-
- try {
- double value = Double.parseDouble(cpuPowerText);
- if (!Double.isFinite(value) || value < 0) {
- return null;
- }
- return value;
- } catch (NumberFormatException e) {
- logger.log(Level.FINE, () -> "Could not parse power value: " + cpuPowerText);
- return null;
- }
- }
-
- static boolean isHeaderLine(String line) {
- // A data row's first column is a numeric timestamp; any non-numeric first column
- // is treated as a header/comment row. This is structurally robust and avoids
- // false positives from data rows that happen to contain known column names.
- int comma = line.indexOf(',');
- String first = (comma == -1 ? line : line.substring(0, comma)).trim();
- if (first.isEmpty()) {
- return true;
- }
- try {
- Double.parseDouble(first);
- return false;
- } catch (NumberFormatException e) {
- return true;
- }
- }
-
- private void updateFileMetadata(long length, long modified) {
- lastKnownLength = length;
- lastKnownModified = modified;
- }
-}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSource.java
deleted file mode 100644
index 4f4357c..0000000
--- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSource.java
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.source;
-
-import java.io.ByteArrayInputStream;
-import java.io.InputStream;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.net.http.HttpClient;
-import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
-import java.nio.charset.StandardCharsets;
-import java.time.Duration;
-import java.util.Locale;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import tools.jackson.core.JsonParser;
-import tools.jackson.core.JsonToken;
-import tools.jackson.core.ObjectReadContext;
-import tools.jackson.core.json.JsonFactory;
-
-public class JoularCoreHttpSource implements PowerSource {
-
- private static final Logger logger = Logger.getLogger(JoularCoreHttpSource.class.getName());
- private static final int MAX_BODY_BYTES = 64 * 1024;
- private static final JsonFactory JSON_FACTORY = JsonFactory.builder().build();
-
- private final URI uri;
- private final HttpClient httpClient;
- private final ExecutorService httpExecutor;
- private double lastKnownPower = 0.0;
-
- public JoularCoreHttpSource(String url) {
- this.uri = parseAndValidate(url);
- this.httpExecutor = Executors.newSingleThreadExecutor(r -> {
- Thread t = new Thread(r, "Joular-Http-Source");
- t.setDaemon(true);
- return t;
- });
- this.httpClient = HttpClient.newBuilder()
- .connectTimeout(Duration.ofSeconds(2))
- .executor(httpExecutor)
- .build();
- }
-
- private static URI parseAndValidate(String url) {
- if (url == null || url.trim().isEmpty()) {
- throw new IllegalArgumentException("joular-core-http-url is empty");
- }
- URI parsed;
- try {
- parsed = new URI(url.trim());
- } catch (URISyntaxException e) {
- throw new IllegalArgumentException("Invalid joular-core-http-url: " + url, e);
- }
- String scheme = parsed.getScheme();
- if (scheme == null) {
- throw new IllegalArgumentException(
- "joular-core-http-url must use http or https scheme: " + url);
- }
- String lower = scheme.toLowerCase(Locale.ROOT);
- if (!lower.equals("http") && !lower.equals("https")) {
- throw new IllegalArgumentException(
- "joular-core-http-url must use http or https scheme, got '" + scheme + "': " + url);
- }
- if (parsed.getHost() == null || parsed.getHost().isEmpty()) {
- throw new IllegalArgumentException("joular-core-http-url is missing host: " + url);
- }
- return parsed;
- }
-
- @Override
- public void initialize() throws Exception {
- logger.log(Level.INFO, () -> "Initializing HTTP source: " + uri);
- }
-
- @Override
- public double getCurrentPower() {
- try {
- HttpRequest request = HttpRequest.newBuilder()
- .uri(uri)
- .timeout(Duration.ofSeconds(1))
- .GET()
- .build();
-
- HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
- if (response.statusCode() != 200) {
- logger.log(Level.FINE, () -> "HTTP data fetch failed with status code: " + response.statusCode());
- return lastKnownPower;
- }
-
- long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1L);
- if (contentLength > MAX_BODY_BYTES) {
- logger.log(Level.WARNING,
- () -> "HTTP response Content-Length " + contentLength
- + " exceeds " + MAX_BODY_BYTES + " bytes; ignoring.");
- return lastKnownPower;
- }
-
- byte[] bytes;
- try (InputStream body = response.body()) {
- bytes = body.readNBytes(MAX_BODY_BYTES + 1);
- }
- if (bytes.length > MAX_BODY_BYTES) {
- logger.log(Level.WARNING,
- () -> "HTTP response exceeded " + MAX_BODY_BYTES + " bytes; ignoring.");
- return lastKnownPower;
- }
-
- Double parsed = extractCpuPower(bytes);
- if (parsed != null) {
- lastKnownPower = parsed;
- }
- return lastKnownPower;
- } catch (Exception e) {
- logger.log(Level.FINE, "Error fetching HTTP power data", e);
- return lastKnownPower;
- }
- }
-
- static Double extractCpuPower(String json) {
- if (json == null) {
- return null;
- }
- return extractCpuPower(json.getBytes(StandardCharsets.UTF_8));
- }
-
- static Double extractCpuPower(byte[] json) {
- if (json == null || json.length == 0) {
- return null;
- }
-
- try (JsonParser parser = JSON_FACTORY.createParser(
- ObjectReadContext.empty(),
- new ByteArrayInputStream(json))) {
- if (parser.nextToken() != JsonToken.START_OBJECT) {
- return null;
- }
-
- while (parser.nextToken() == JsonToken.PROPERTY_NAME) {
- String propertyName = parser.currentName();
- JsonToken valueToken = parser.nextToken();
- if ("cpu_power".equals(propertyName)) {
- if (valueToken != JsonToken.VALUE_NUMBER_INT && valueToken != JsonToken.VALUE_NUMBER_FLOAT) {
- return null;
- }
- double value = parser.getDoubleValue();
- if (!Double.isFinite(value) || value < 0) {
- return null;
- }
- return value;
- }
- parser.skipChildren();
- }
- } catch (Exception e) {
- logger.log(Level.FINE, "Could not parse HTTP power JSON", e);
- }
- return null;
- }
-
- @Override
- public void close() {
- httpExecutor.shutdownNow();
- }
-}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSource.java
deleted file mode 100644
index 97a218e..0000000
--- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSource.java
+++ /dev/null
@@ -1,170 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.source;
-
-import com.sun.jna.Library;
-import com.sun.jna.Native;
-import com.sun.jna.Pointer;
-import com.sun.jna.PointerType;
-import com.sun.jna.WString;
-import java.io.RandomAccessFile;
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
-import java.nio.channels.FileChannel;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-public class JoularCoreRingBufferSource implements PowerSource {
-
- private static final Logger logger = Logger.getLogger(JoularCoreRingBufferSource.class.getName());
- private static final boolean IS_WINDOWS = System.getProperty("os.name").toLowerCase().contains("win");
- private static final int ENTRY_SIZE = 48; // u64 timestamp + 5 * f64 (8 bytes each)
- private static final int BUFFER_SIZE = 5;
- private static final int FILE_SIZE = 8 + BUFFER_SIZE * ENTRY_SIZE;
- private static final int STALENESS_THRESHOLD = 10;
-
- private final String path;
- private ByteBuffer buffer;
- private HANDLE mappingHandle;
- private Pointer mappingPointer;
- private double lastKnownPower = 0.0;
- private long lastObservedHead = Long.MIN_VALUE;
- private int staleCycles = 0;
- private boolean staleWarningLogged = false;
-
- interface Kernel32 extends Library {
- Kernel32 INSTANCE = Native.load("kernel32", Kernel32.class);
-
- int FILE_MAP_READ = 0x0004;
-
- HANDLE OpenFileMappingW(int desiredAccess, boolean inheritHandle, WString name);
- Pointer MapViewOfFile(HANDLE hFileMappingObject, int dwDesiredAccess, int dwFileOffsetHigh, int dwFileOffsetLow, int dwNumberOfBytesToMap);
- boolean UnmapViewOfFile(Pointer lpBaseAddress);
- boolean CloseHandle(HANDLE hObject);
- }
-
- public static class HANDLE extends PointerType {
- public HANDLE() {
- super();
- }
- }
-
- public JoularCoreRingBufferSource(String path) {
- this.path = path;
- }
-
- @Override
- public void initialize() throws Exception {
- logger.log(Level.INFO, () -> "Initializing ring buffer source: " + path);
- if (IS_WINDOWS) {
- if (Files.exists(Path.of(path)) && Files.isRegularFile(Path.of(path))) {
- mapFile(path);
- } else {
- mapWindowsSharedMemory(path);
- }
- } else {
- mapFile(path);
- }
- }
-
- private void mapFile(String filePath) throws Exception {
- try (RandomAccessFile file = new RandomAccessFile(filePath, "r")) {
- this.buffer = file.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, FILE_SIZE);
- }
- this.buffer.order(ByteOrder.nativeOrder());
- }
-
- private void mapWindowsSharedMemory(String mappingName) throws Exception {
- mappingHandle = Kernel32.INSTANCE.OpenFileMappingW(Kernel32.FILE_MAP_READ, false, new WString(mappingName));
- if (mappingHandle == null || Pointer.nativeValue(mappingHandle.getPointer()) == 0) {
- throw new Exception("Could not open file mapping '" + mappingName + "'");
- }
-
- mappingPointer = Kernel32.INSTANCE.MapViewOfFile(mappingHandle, Kernel32.FILE_MAP_READ, 0, 0, FILE_SIZE);
- if (mappingPointer == null || Pointer.nativeValue(mappingPointer) == 0) {
- Kernel32.INSTANCE.CloseHandle(mappingHandle);
- mappingHandle = null;
- throw new Exception("Could not map view of file '" + mappingName + "'");
- }
-
- this.buffer = mappingPointer.getByteBuffer(0, FILE_SIZE);
- this.buffer.order(ByteOrder.nativeOrder());
- }
-
- @Override
- public double getCurrentPower() {
- if (buffer == null) return 0;
-
- // Load-verify pattern: the producer (Joular Core) is a separate process,
- // so the data entry at the head slot may still be mid-write when we read it.
- // Re-read head after the data load; if head advanced, discard this read.
- long head1 = buffer.getLong(0);
- if (head1 <= 0) {
- trackStaleness(head1);
- return lastKnownPower;
- }
-
- int idx = (int) ((head1 - 1) % BUFFER_SIZE);
- int offset = 8 + idx * ENTRY_SIZE;
- // RingBufferStruct: timestamp (u64), cpu_power, gpu_power, total_power, cpu_usage, pid_app_power
- // cpu_power is the 2nd field; skip 8-byte timestamp
- double value = buffer.getDouble(offset + 8);
-
- long head2 = buffer.getLong(0);
- if (head1 != head2) {
- // Writer advanced mid-read; drop this sample rather than risk a torn value.
- trackStaleness(head2);
- return lastKnownPower;
- }
-
- trackStaleness(head1);
- if (!Double.isFinite(value) || value < 0) {
- return lastKnownPower;
- }
- lastKnownPower = value;
- return value;
- }
-
- private void trackStaleness(long currentHead) {
- if (currentHead != lastObservedHead) {
- lastObservedHead = currentHead;
- staleCycles = 0;
- staleWarningLogged = false;
- return;
- }
-
- staleCycles++;
- if (staleCycles == STALENESS_THRESHOLD && !staleWarningLogged) {
- logger.log(Level.WARNING,
- "Joular Core ring buffer appears stale - head has not advanced for "
- + STALENESS_THRESHOLD + " reads. Is Joular Core still running?");
- staleWarningLogged = true;
- }
- }
-
- @Override
- public void close() {
- if (IS_WINDOWS) {
- if (mappingPointer != null) {
- Kernel32.INSTANCE.UnmapViewOfFile(mappingPointer);
- mappingPointer = null;
- }
- if (mappingHandle != null) {
- Kernel32.INSTANCE.CloseHandle(mappingHandle);
- mappingHandle = null;
- }
- }
- buffer = null;
- }
-}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSource.java
deleted file mode 100644
index 09737fa..0000000
--- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSource.java
+++ /dev/null
@@ -1,183 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.source;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.time.Clock;
-import java.time.Duration;
-import java.time.Instant;
-import java.util.Locale;
-import java.util.Objects;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-public class LinuxRaplPowerSource implements PowerSource {
-
- private static final Logger logger = Logger.getLogger(LinuxRaplPowerSource.class.getName());
- private static final String DEFAULT_PKG_PATH = "/sys/class/powercap/intel-rapl/intel-rapl:0";
- private static final String EXPECTED_PKG_NAME = "package-0";
- private static final double MICROJOULES_PER_JOULE = 1_000_000.0;
-
- private final Path packagePath;
- private final String osName;
- private final Clock clock;
-
- private Path energyPath;
- private double maxEnergyRangeJoules;
- private double lastEnergyJoules;
- private Instant lastReadAt;
- private double lastKnownPower = 0.0;
- private boolean initialized = false;
-
- public LinuxRaplPowerSource() {
- this(System.getProperty("os.name", ""));
- }
-
- private LinuxRaplPowerSource(String osName) {
- this(defaultPackagePath(osName), osName, Clock.systemUTC());
- }
-
- LinuxRaplPowerSource(Path packagePath, String osName, Clock clock) {
- this.packagePath = Objects.requireNonNull(packagePath, "packagePath");
- this.osName = osName == null ? "" : osName;
- this.clock = Objects.requireNonNull(clock, "clock");
- }
-
- @Override
- public void initialize() throws Exception {
- if (!isLinux()) {
- throw new IllegalStateException("Linux RAPL power source is only supported on Linux.");
- }
-
- Path namePath = packagePath.resolve("name");
- Path maxEnergyRangePath = packagePath.resolve("max_energy_range_uj");
- energyPath = packagePath.resolve("energy_uj");
-
- requireReadableFile(namePath, "RAPL package name");
- requireReadableFile(energyPath, "RAPL package energy");
- requireReadableFile(maxEnergyRangePath, "RAPL package max energy range");
-
- String packageName = Files.readString(namePath).trim();
- if (!EXPECTED_PKG_NAME.equals(packageName)) {
- throw new IOException("Unsupported RAPL package domain '" + packageName
- + "' at " + packagePath + "; expected " + EXPECTED_PKG_NAME + ".");
- }
-
- maxEnergyRangeJoules = readJoules(maxEnergyRangePath);
- if (!Double.isFinite(maxEnergyRangeJoules) || maxEnergyRangeJoules <= 0) {
- throw new IOException("Invalid RAPL max energy range: " + maxEnergyRangeJoules + " J.");
- }
-
- lastEnergyJoules = readEnergyJoules();
- lastReadAt = clock.instant();
- lastKnownPower = 0.0;
- initialized = true;
-
- logger.log(Level.INFO, () -> "Initialized Linux RAPL PKG source: " + packagePath);
- }
-
- @Override
- public double getCurrentPower() {
- if (!initialized) {
- return 0.0;
- }
-
- try {
- double currentEnergyJoules = readEnergyJoules();
- Instant currentReadAt = clock.instant();
- double elapsedSeconds = elapsedSeconds(lastReadAt, currentReadAt);
- if (elapsedSeconds <= 0 || !Double.isFinite(elapsedSeconds)) {
- return lastKnownPower;
- }
-
- double energyDelta = currentEnergyJoules >= lastEnergyJoules
- ? currentEnergyJoules - lastEnergyJoules
- : currentEnergyJoules - lastEnergyJoules + maxEnergyRangeJoules;
- if (!Double.isFinite(energyDelta) || energyDelta < 0) {
- return lastKnownPower;
- }
-
- double power = energyDelta / elapsedSeconds;
- if (!Double.isFinite(power) || power < 0) {
- return lastKnownPower;
- }
-
- lastEnergyJoules = currentEnergyJoules;
- lastReadAt = currentReadAt;
- lastKnownPower = power;
- return power;
- } catch (Exception e) {
- logger.log(Level.FINE, "Could not read Linux RAPL power data", e);
- return lastKnownPower;
- }
- }
-
- @Override
- public void close() {
- initialized = false;
- energyPath = null;
- maxEnergyRangeJoules = 0.0;
- lastEnergyJoules = 0.0;
- lastReadAt = null;
- lastKnownPower = 0.0;
- }
-
- private boolean isLinux() {
- return isLinux(osName);
- }
-
- private static boolean isLinux(String osName) {
- return osName != null && osName.toLowerCase(Locale.ROOT).contains("linux");
- }
-
- private static Path defaultPackagePath(String osName) {
- if (isLinux(osName)) {
- return Path.of(DEFAULT_PKG_PATH);
- }
- return Path.of(".");
- }
-
- private double readEnergyJoules() throws IOException {
- double energyJoules = readJoules(energyPath);
- if (!Double.isFinite(energyJoules) || energyJoules < 0 || energyJoules > maxEnergyRangeJoules) {
- throw new IOException("Invalid RAPL energy reading: " + energyJoules + " J.");
- }
- return energyJoules;
- }
-
- private static double readJoules(Path path) throws IOException {
- String text = Files.readString(path).trim();
- double microjoules;
- try {
- microjoules = Double.parseDouble(text);
- } catch (NumberFormatException e) {
- throw new IOException("Could not parse RAPL value from " + path + ": " + text, e);
- }
- return microjoules / MICROJOULES_PER_JOULE;
- }
-
- private static void requireReadableFile(Path path, String description) throws IOException {
- if (!Files.isRegularFile(path) || !Files.isReadable(path)) {
- throw new IOException(description + " file is not readable: " + path);
- }
- }
-
- private static double elapsedSeconds(Instant start, Instant end) {
- if (start == null || end == null) {
- return 0.0;
- }
- Duration elapsed = Duration.between(start, end);
- return elapsed.toNanos() / 1_000_000_000.0;
- }
-}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java
deleted file mode 100644
index e4c6bc1..0000000
--- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSource.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.source;
-
-/**
- * Interface for different power data sources.
- *
- * All implementations must follow the same failure semantics:
- *
- * - Before any successful read, {@link #getCurrentPower()} returns {@code 0.0}.
- * - On a successful read, the returned value is cached as the "last known power".
- * - On transient errors (network blip, mid-write torn reads, parse failures, missing
- * row, temporarily unreadable device data), implementations return the last known power
- * so the agent smooths over momentary producer unavailability.
- * - Implementations must reject {@code NaN}, infinite, and negative values returned by
- * the underlying source — these are treated as transient errors.
- * - Returned values are CPU power in Watts and must be {@code >= 0} and finite.
- *
- */
-public interface PowerSource {
- /**
- * Initialize the power source connection.
- *
- * @throws Exception if the source cannot be opened. The agent will log and abort startup.
- */
- void initialize() throws Exception;
-
- /**
- * Get the current power consumption in Watts.
- *
- * @return power in Watts; always finite and {@code >= 0}.
- */
- double getCurrentPower();
-
- /**
- * Close the power source connection. Must be safe to call even if {@link #initialize()}
- * was never called or threw.
- */
- void close();
-}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactory.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactory.java
deleted file mode 100644
index 2ca03da..0000000
--- a/src/main/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactory.java
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.source;
-
-import java.util.Locale;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties;
-
-public class PowerSourceFactory {
-
- private static final Logger logger = Logger.getLogger(PowerSourceFactory.class.getName());
-
- public static PowerSource getPowerSource(AgentProperties properties) {
- String type = properties.getPowerSourceType();
- String normalizedType = type.trim().toLowerCase(Locale.ROOT);
- logger.log(Level.INFO, () -> "Selecting power source type: " + type);
- try {
- return switch (normalizedType) {
- case "csv" -> new JoularCoreCSVSource(properties.getJoularCoreCsvPath());
- case "http" -> new JoularCoreHttpSource(properties.getJoularCoreHttpUrl());
- case "ringbuffer" -> new JoularCoreRingBufferSource(properties.getRingBufferPath());
- case "rapl" -> new LinuxRaplPowerSource();
- default -> {
- logger.log(Level.SEVERE, () -> "Unknown power source type: " + type);
- yield null;
- }
- };
- } catch (IllegalArgumentException e) {
- logger.log(Level.SEVERE, "Invalid configuration for power source '" + type + "': " + e.getMessage());
- return null;
- }
- }
-}
diff --git a/src/main/java/org/noureddine/joular/joularcodejava/agent/utils/AgentProperties.java b/src/main/java/org/noureddine/joular/joularcodejava/agent/utils/AgentProperties.java
deleted file mode 100644
index be07c5a..0000000
--- a/src/main/java/org/noureddine/joular/joularcodejava/agent/utils/AgentProperties.java
+++ /dev/null
@@ -1,147 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.utils;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Properties;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-public class AgentProperties {
-
- private static final Logger logger = Logger.getLogger(AgentProperties.class.getName());
- private final Properties properties = new Properties();
-
- public AgentProperties() {
- loadClasspathDefaults();
-
- // Check system properties; fall back to joularcodejava.properties in the working directory
- String rawPath = System.getProperty("joularcodejava.properties");
- Path configFilePath = (rawPath == null || rawPath.isEmpty())
- ? Path.of(System.getProperty("user.dir")).resolve("joularcodejava.properties")
- : Path.of(rawPath);
-
- // If file does not exist, load defaults
- if (!Files.exists(configFilePath) || !Files.isRegularFile(configFilePath)) {
- logger.log(Level.INFO, "Could not load properties from " + configFilePath + ", using defaults.");
- return;
- }
-
- // Load properties from file (UTF-8 so users can write non-ASCII paths/values)
- try (Reader in = Files.newBufferedReader(configFilePath, StandardCharsets.UTF_8)) {
- properties.load(in);
- } catch (IOException e) {
- logger.log(Level.WARNING, "Could not load properties from " + configFilePath + ", using defaults.");
- }
- }
-
- public String getPowerSourceType() {
- return getStringProperty("power-source-type", "ringbuffer");
- }
-
- public String getJoularCoreCsvPath() {
- return getStringProperty("joular-core-csv-path", "joularcore-data.csv");
- }
-
- public String getJoularCoreHttpUrl() {
- return getStringProperty("joular-core-http-url", "http://localhost:8080/data");
- }
-
- public String getRingBufferPath() {
- String defaultPath;
- String os = System.getProperty("os.name").toLowerCase();
- if (os.contains("win")) {
- defaultPath = "Local\\JoularCoreRing";
- } else if (os.contains("mac")) {
- defaultPath = "/tmp/joularcorering";
- } else {
- defaultPath = "/dev/shm/joularcorering";
- }
- return getStringProperty("joular-core-ringbuffer-path", defaultPath);
- }
-
- public long getSampleRateMs() {
- return getPositiveLongProperty("stack-monitoring-sample-rate", 10L);
- }
-
- public String getResultsPath() {
- return getStringProperty("results-path", "joular-code-java-results");
- }
-
- public List getMethodsFilteringPrefixes() {
- String value = getStringProperty("methods-filtering-prefix", "");
- if (value.isEmpty()) {
- return Collections.emptyList();
- }
-
- String[] rawPrefixes = value.split(",");
- List prefixes = new ArrayList<>(rawPrefixes.length);
- for (String prefix : rawPrefixes) {
- if (prefix != null) {
- String cleaned = prefix.trim();
- if (!cleaned.isEmpty()) {
- prefixes.add(cleaned);
- }
- }
- }
-
- return prefixes;
- }
-
- private void loadClasspathDefaults() {
- try (InputStream in = AgentProperties.class.getClassLoader().getResourceAsStream("joularcodejava.properties")) {
- if (in == null) {
- return;
- }
- try (Reader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) {
- properties.load(reader);
- }
- } catch (IOException e) {
- logger.log(Level.FINE, "Could not load classpath default properties.", e);
- }
- }
-
- private String getStringProperty(String key, String defaultValue) {
- String value = properties.getProperty(key);
- if (value == null) {
- return defaultValue;
- }
- String trimmed = value.trim();
- return trimmed.isEmpty() ? defaultValue : trimmed;
- }
-
- private long getPositiveLongProperty(String key, long defaultValue) {
- String rawValue = properties.getProperty(key);
- if (rawValue == null || rawValue.trim().isEmpty()) {
- return defaultValue;
- }
- try {
- long value = Long.parseLong(rawValue.trim());
- if (value > 0) {
- return value;
- }
- logger.log(Level.WARNING, () -> "Property " + key + " must be > 0. Using default: " + defaultValue);
- } catch (NumberFormatException e) {
- logger.log(Level.WARNING, () -> "Property " + key + " has invalid value '" + rawValue + "'. Using default: " + defaultValue);
- }
- return defaultValue;
- }
-}
diff --git a/src/main/resources/joularcodejava.properties b/src/main/resources/joularcodejava.properties
deleted file mode 100644
index a5472ec..0000000
--- a/src/main/resources/joularcodejava.properties
+++ /dev/null
@@ -1,55 +0,0 @@
-# Copyright (c) 2026, Adel Noureddine.
-# All rights reserved. This program and the accompanying materials
-# are made available under the terms of the
-# GNU Lesser General Public License v3.0 (LGPL-3.0-only)
-# which accompanies this distribution, and is available at
-# https://www.gnu.org/licenses/lgpl-3.0.en.html
-#
-# Author : Adel Noureddine
-
-# Joular Code - Java: Configuration
-#
-# Power source type from Joular Core
-# Options:
-# csv (read power from CSV file)
-# http (read from HTTP endpoint/websockets)
-# ringbuffer (read from shared memory ring buffer, IPC)
-# rapl (read from RAPL on Linux directly, without Joular Core)
-# Default if left empty: ringbuffer
-power-source-type=
-
-# Path to Joular Core CSV file (if power-source-type is csv)
-# Default if left empty: joularcore-data.csv (relative to working directory)
-joular-core-csv-path=
-
-# Joular Core API URL (if power-source-type is http)
-# Default if left empty: http://localhost:8080/data
-joular-core-http-url=
-
-# Path to ring buffer (if power-source-type is ringbuffer)
-# Default if left empty (OS-dependent):
-# Linux: /dev/shm/joularcorering
-# macOS: /tmp/joularcorering
-# Windows: Local\\JoularCoreRing
-joular-core-ringbuffer-path=
-
-# The sample rate (milliseconds) for the agent to monitor methods usage
-# by calling the JVM stack
-# Higher count means less accurate energy data
-# Lower count means higher overhead of the agent
-# Default if left empty: 10 (good balance between accuracy and efficiency)
-stack-monitoring-sample-rate=
-
-# Output folder for results
-# Default if left empty: joular-code-java-results
-results-path=
-
-# Prefix to filter monitoring of methods
-# If set, only methods where their full canonical name (package.class.method) starting with this prefix will be monitored
-# If not set, all observed branches appear in both output files.
-# Example: methods-filtering-prefix=com.example to filter energy for all methods whose full name start with com.example
-# Note that Joular Code Java will always generate energy for all non-filtered methods in a separate file
-# You can add multiple identifiers names separated by a comma, ordered by priority
-# Example: methods-filtering-prefix=com.example,org.myapp
-# Default if left empty: no filter (all branches go to both output files)
-methods-filtering-prefix=
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/AgentEntryPointTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/AgentEntryPointTest.java
new file mode 100644
index 0000000..ba877b3
--- /dev/null
+++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/AgentEntryPointTest.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.instrument.Instrumentation;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** The manifest names Agent for Premain-Class and Agent-Class, and the JVM looks these two methods up by signature. */
+class AgentEntryPointTest {
+
+ /** Loaded by the JVM when the agent is given on the command line with {@code -javaagent:}. */
+ @Test
+ void premain_hasTheSignatureTheJvmLooksUp() throws Exception {
+ assertIsAgentEntryPoint("premain");
+ }
+
+ /** Loaded by the JVM when the agent is attached to an already running process. */
+ @Test
+ void agentmain_hasTheSignatureTheAttachApiLooksUp() throws Exception {
+ assertIsAgentEntryPoint("agentmain");
+ }
+
+ private static void assertIsAgentEntryPoint(String name) throws Exception {
+ Method method = Agent.class.getMethod(name, String.class, Instrumentation.class);
+
+ int modifiers = method.getModifiers();
+ assertTrue(Modifier.isPublic(modifiers), name + " must be public");
+ assertTrue(Modifier.isStatic(modifiers), name + " must be static");
+ assertEquals(void.class, method.getReturnType(), name + " must return void");
+ }
+}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/BranchTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/BranchTest.java
new file mode 100644
index 0000000..5ff58ee
--- /dev/null
+++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/BranchTest.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/** Branch identity, which decides what adds up, and its two names, which feed the two result files. */
+class BranchTest {
+
+ /**
+ * A branch the way the JVM hands a stack over: newest frame at index 0.
+ *
+ * @param methods {@code package.Class.method} names, oldest first, which is the order they come back out in
+ */
+ static Branch branch(String... methods) {
+ StackTraceElement[] frames = new StackTraceElement[methods.length];
+ for (int i = 0; i < methods.length; i++) {
+ String qualified = methods[methods.length - 1 - i];
+ int lastDot = qualified.lastIndexOf('.');
+ frames[i] = new StackTraceElement(qualified.substring(0, lastDot), qualified.substring(lastDot + 1), null, i);
+ }
+ return Branch.of(frames);
+ }
+
+ @Test
+ void name_walksTheStackOldestFrameFirst() {
+ assertEquals("com.example.Main.main;com.example.Worker.compute",
+ branch("com.example.Main.main", "com.example.Worker.compute").name());
+ }
+
+ @Test
+ void name_singleFrame_hasNoSeparator() {
+ assertEquals("com.example.Main.main", branch("com.example.Main.main").name());
+ }
+
+ /** Samples on different lines of the same methods are one branch, so their counts add up. */
+ @Test
+ void equals_ignoresLineNumbers() {
+ StackTraceElement[] onLine10 = {new StackTraceElement("a.Worker", "compute", "Worker.java", 10)};
+ StackTraceElement[] onLine20 = {new StackTraceElement("a.Worker", "compute", "Worker.java", 20)};
+
+ assertEquals(Branch.of(onLine10), Branch.of(onLine20));
+ assertEquals(Branch.of(onLine10).hashCode(), Branch.of(onLine20).hashCode());
+ }
+
+ @Test
+ void equals_differentMethodsOrDepth_areDifferentBranches() {
+ assertNotEquals(branch("a.Main.main", "a.Worker.compute"), branch("a.Main.main", "a.Worker.save"));
+ assertNotEquals(branch("a.Main.main", "a.Worker.compute"), branch("a.Main.main"));
+ assertNotEquals(branch("a.Main.main", "a.Worker.compute"), branch("a.Worker.compute", "a.Main.main"));
+ }
+
+ @Test
+ void appName_keepsOnlyMatchingFrames() {
+ Branch branch = branch("java.lang.Thread.run", "com.example.Worker.compute", "java.util.HashMap.put");
+
+ assertEquals("java.lang.Thread.run;com.example.Worker.compute;java.util.HashMap.put", branch.name());
+ assertEquals("com.example.Worker.compute", branch.appName(List.of("com.example")));
+ }
+
+ @Test
+ void appName_nothingMatches_isEmpty() {
+ assertEquals("", branch("java.lang.Thread.run", "java.util.HashMap.put").appName(List.of("com.example")),
+ "an unmatched stack contributes nothing to the app file");
+ }
+
+ /** Unmatched frames called by a matched method collapse onto it, so stacks that differ only below it add up. */
+ @Test
+ void appName_unmatchedFramesBelowAMatch_collapseOntoTheMatchedMethod() {
+ List prefixes = List.of("com.example");
+
+ assertEquals("com.example.Service.save",
+ branch("com.example.Service.save", "java.util.HashMap.put").appName(prefixes));
+ assertEquals("com.example.Service.save",
+ branch("com.example.Service.save", "java.io.FileOutputStream.write").appName(prefixes));
+ }
+
+ @Test
+ void appName_multiplePrefixes_eachOneMatches() {
+ assertEquals("org.myapp.Boot.start;com.example.Worker.compute",
+ branch("org.myapp.Boot.start", "java.util.HashMap.put", "com.example.Worker.compute")
+ .appName(List.of("com.example", "org.myapp")));
+ }
+
+ /** The prefix is plain text, not a package boundary, as documented for the property. */
+ @Test
+ void appName_prefixMatchesTextNotPackageBoundaries() {
+ assertEquals("com.examples.Other.run;com.example.Worker.compute",
+ branch("com.examples.Other.run", "com.example.Worker.compute").appName(List.of("com.example")));
+ }
+
+ @Test
+ void startsWith_prefixReachingIntoTheMethodName() {
+ assertTrue(Branch.startsWith("com.example.Worker", "compute", "com.example.Worker.com"));
+ assertTrue(Branch.startsWith("com.example.Worker", "compute", "com.example.Worker.compute"));
+ assertTrue(Branch.startsWith("com.example.Worker", "compute", "com.example.Worker."));
+
+ assertFalse(Branch.startsWith("com.example.Worker", "compute", "com.example.Worker.x"));
+ assertFalse(Branch.startsWith("com.example.Worker", "compute", "com.example.Worker.computed"),
+ "a prefix longer than the whole name cannot match");
+ assertFalse(Branch.startsWith("com.example.Worker", "compute", "com.example.Workers"));
+ }
+
+ @Test
+ void startsWith_prefixInsideTheClassName() {
+ assertTrue(Branch.startsWith("com.example.Worker", "compute", "com.example"));
+ assertTrue(Branch.startsWith("com.example.Worker", "compute", "com.example.Worker"));
+ assertFalse(Branch.startsWith("com.example.Worker", "compute", "org.other"));
+ }
+}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/ConfigTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/ConfigTest.java
new file mode 100644
index 0000000..3e75f75
--- /dev/null
+++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/ConfigTest.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.Properties;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class ConfigTest {
+
+ @TempDir
+ Path tempDir;
+
+ @AfterEach
+ void clearSystemProperty() {
+ System.clearProperty("joularcodejava.properties");
+ }
+
+ private static Config config(String content) throws IOException {
+ Properties properties = new Properties();
+ properties.load(new StringReader(content));
+ return Config.from(properties);
+ }
+
+ @Test
+ void defaults() throws IOException {
+ Config config = config("");
+
+ assertEquals("auto", config.powerSourceType());
+ assertNull(config.vmPowerFile());
+ assertEquals("powerjoular", config.vmPowerFormat());
+ assertEquals(10L, config.sampleRateMs());
+ assertEquals(Path.of("joular-code-java-results"), config.resultsDir());
+ assertTrue(config.appPrefixes().isEmpty());
+ }
+
+ /** A value of blanks only is the same as no value. */
+ @Test
+ void blankValue_usesTheDefault() throws IOException {
+ assertEquals("auto", config("power-source-type= \n").powerSourceType());
+ }
+
+ @Test
+ void values_areReadAndTrimmed() throws IOException {
+ Config config = config("""
+ power-source-type = RingBuffer
+ powerjoular-ringbuffer-path = /custom/ring
+ vm-power-file = /mnt/host/vm-power.csv
+ vm-power-format = Watts
+ stack-monitoring-sample-rate = 50
+ results-path = out
+ """);
+
+ assertEquals("ringbuffer", config.powerSourceType(), "the type is not case sensitive");
+ assertEquals(Path.of("/custom/ring"), config.ringBufferPath());
+ assertEquals(Path.of("/mnt/host/vm-power.csv"), config.vmPowerFile());
+ assertEquals("watts", config.vmPowerFormat());
+ assertEquals(50L, config.sampleRateMs());
+ assertEquals(Path.of("out"), config.resultsDir());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"0", "-5", "fast", "1001", "99999999999999999999"})
+ void invalidSampleRate_usesTheDefault(String value) throws IOException {
+ assertEquals(Config.DEFAULT_SAMPLE_RATE_MS, config("stack-monitoring-sample-rate=" + value).sampleRateMs());
+ }
+
+ @Test
+ void prefixes_areSplitTrimmedAndEmptyOnesDropped() throws IOException {
+ assertEquals(List.of("com.example", "org.myapp", "io.foo"),
+ config("methods-filtering-prefix=com.example, org.myapp ,, io.foo").appPrefixes());
+ }
+
+ @Test
+ void ringBufferPath_defaultDependsOnTheOs() throws IOException {
+ String os = System.getProperty("os.name").toLowerCase(Locale.ROOT);
+ String path = config("").ringBufferPath().toString();
+
+ if (os.contains("win")) {
+ assertTrue(path.endsWith("\\powerjoular"), path);
+ } else if (os.contains("mac")) {
+ assertEquals("/tmp/powerjoular", path);
+ } else {
+ assertEquals("/dev/shm/powerjoular", path);
+ }
+ }
+
+ @Test
+ void load_readsTheFileNamedBySystemProperty() throws IOException {
+ Path file = tempDir.resolve("custom.properties");
+ Files.writeString(file, "stack-monitoring-sample-rate=25\n", StandardCharsets.UTF_8);
+ System.setProperty("joularcodejava.properties", file.toString());
+
+ assertEquals(25L, Config.load().sampleRateMs());
+ }
+
+ @Test
+ void load_missingFile_usesDefaults() {
+ System.setProperty("joularcodejava.properties", tempDir.resolve("missing.properties").toString());
+
+ assertEquals(Config.DEFAULT_SAMPLE_RATE_MS, Config.load().sampleRateMs());
+ }
+}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/LinuxRaplTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/LinuxRaplTest.java
new file mode 100644
index 0000000..08c6374
--- /dev/null
+++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/LinuxRaplTest.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * RAPL read from a fake powercap tree. Its zone names hold a ':', which Windows does not allow in a file name, so
+ * those tests only run elsewhere.
+ */
+class LinuxRaplTest {
+
+ private static final long SECOND = 1_000_000_000L;
+ private static final long MAX_UJ = 10_000_000;
+
+ @TempDir
+ Path powercap;
+
+ /** The time the source reads, moved by the tests. */
+ private long now = 0;
+
+ private LinuxRapl open() throws IOException {
+ return LinuxRapl.open(powercap, () -> now);
+ }
+
+ private void zone(String dirName, String name, long energyUj) throws IOException {
+ Path zone = Files.createDirectories(powercap.resolve(dirName));
+ Files.writeString(zone.resolve("name"), name + "\n");
+ Files.writeString(zone.resolve("max_energy_range_uj"), MAX_UJ + "\n");
+ energy(dirName, String.valueOf(energyUj));
+ }
+
+ private void energy(String dirName, String energyUj) throws IOException {
+ Files.writeString(powercap.resolve(dirName).resolve("energy_uj"), energyUj + "\n");
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void watts_isTheEnergyUsedOverTheTimeElapsed() throws IOException {
+ zone("intel-rapl:0", "package-0", 1_000_000);
+ LinuxRapl rapl = open();
+
+ energy("intel-rapl:0", "5000000");
+ now += 2 * SECOND;
+
+ assertEquals(2.0, rapl.watts(), 1e-9);
+ }
+
+ /** Sub-zones are part of their package, and psys covers more than the CPU. */
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void watts_sumsThePackagesOnly() throws IOException {
+ zone("intel-rapl:0", "package-0", 0);
+ zone("intel-rapl:1", "package-1", 0);
+ zone("intel-rapl:0:0", "core", 0);
+ zone("intel-rapl:2", "psys", 0);
+ LinuxRapl rapl = open();
+
+ for (String dir : new String[] {"intel-rapl:0", "intel-rapl:1", "intel-rapl:0:0", "intel-rapl:2"}) {
+ energy(dir, "1000000");
+ }
+ now += SECOND;
+
+ assertEquals(2.0, rapl.watts(), 1e-9, "one joule from each package, nothing from the other zones");
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void watts_counterWrapped_countsTheEnergyAcrossTheWrap() throws IOException {
+ zone("intel-rapl:0", "package-0", MAX_UJ - 1_000_000);
+ LinuxRapl rapl = open();
+
+ energy("intel-rapl:0", "1000000");
+ now += SECOND;
+
+ assertEquals(2.0, rapl.watts(), 1e-9);
+ }
+
+ /** A failed reading leaves the counters alone, so the next good one covers the whole time since the last. */
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void watts_afterAFailedReading_averagesOverTheLongerSpan() throws IOException {
+ zone("intel-rapl:0", "package-0", 0);
+ LinuxRapl rapl = open();
+
+ for (String garbage : new String[] {"not-a-number", "-1", String.valueOf(MAX_UJ + 1)}) {
+ energy("intel-rapl:0", garbage);
+ now += SECOND;
+ assertThrows(IOException.class, rapl::watts, garbage);
+ }
+
+ energy("intel-rapl:0", "8000000");
+ now += SECOND;
+ assertEquals(2.0, rapl.watts(), 1e-9, "8 J over 4 s");
+ }
+
+ /** After a suspend the counter restarts near 0, which is not a wrap: that window gets no energy. */
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void watts_counterReset_countsNothingForThatWindow() throws IOException {
+ zone("intel-rapl:0", "package-0", 2_000_000);
+ LinuxRapl rapl = open();
+
+ energy("intel-rapl:0", "1000000");
+ now += SECOND;
+ assertEquals(0.0, rapl.watts(), 1e-9);
+
+ energy("intel-rapl:0", "3000000");
+ now += SECOND;
+ assertEquals(2.0, rapl.watts(), 1e-9, "counting on from the reset value");
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void open_noPackageZone_throws() throws IOException {
+ zone("intel-rapl:0", "psys", 0);
+
+ assertThrows(IOException.class, this::open);
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void open_malformedRange_throws() throws IOException {
+ zone("intel-rapl:0", "package-0", 0);
+ Files.writeString(powercap.resolve("intel-rapl:0/max_energy_range_uj"), "lots\n");
+
+ assertThrows(IOException.class, this::open);
+ }
+
+ @Test
+ void energyBetween() {
+ assertEquals(300, LinuxRapl.energyBetween(100, 400, 1000));
+ assertEquals(0, LinuxRapl.energyBetween(400, 400, 1000));
+ assertEquals(150, LinuxRapl.energyBetween(900, 50, 1000), "wrapped past 1000");
+ assertEquals(0, LinuxRapl.energyBetween(100, 50, 1000), "a jump back over half the range is a reset");
+ }
+}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitorTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitorTest.java
new file mode 100644
index 0000000..a56579c
--- /dev/null
+++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitorTest.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.BooleanSupplier;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * The monitor thread against a fake power source, with a busy thread to measure. The attribution itself is covered
+ * by {@link PowerModelTest}, since live stacks and CPU times cannot be made deterministic.
+ */
+class MonitorTest {
+
+ @TempDir
+ Path tempDir;
+
+ private final List logs = new CopyOnWriteArrayList<>();
+ private final Handler logCapture = new Handler() {
+ @Override
+ public void publish(LogRecord record) {
+ logs.add(record);
+ }
+
+ @Override
+ public void flush() {
+ }
+
+ @Override
+ public void close() {
+ }
+ };
+
+ private final FakeSource source = new FakeSource();
+ private Monitor monitor;
+ private volatile boolean busy = true;
+
+ /** A source with 100 ms windows, so tests do not wait a whole second each. */
+ private static final class FakeSource implements PowerSource {
+ volatile double watts = 50.0;
+ volatile boolean failing = false;
+ volatile int reads = 0;
+ volatile String openedOn;
+ volatile String closedOn;
+
+ @Override
+ public double watts() throws IOException {
+ reads++;
+ if (failing) {
+ throw new IOException("the fake source is failing.");
+ }
+ return watts;
+ }
+
+ @Override
+ public boolean windowOver(long elapsedNs) {
+ return elapsedNs >= 100_000_000L;
+ }
+
+ @Override
+ public void close() {
+ closedOn = Thread.currentThread().getName();
+ }
+ }
+
+ @BeforeEach
+ void captureLogs() {
+ Logger.getLogger(Monitor.class.getName()).addHandler(logCapture);
+ }
+
+ @AfterEach
+ void tearDown() {
+ busy = false;
+ if (monitor != null) {
+ monitor.stop();
+ }
+ Logger.getLogger(Monitor.class.getName()).removeHandler(logCapture);
+ }
+
+ private Monitor monitor(List appPrefixes) throws IOException {
+ Config config = new Config("auto", tempDir.resolve("ring"), null, "powerjoular", 10, tempDir, appPrefixes);
+ monitor = new Monitor(config, () -> {
+ source.openedOn = Thread.currentThread().getName();
+ return source;
+ });
+ return monitor;
+ }
+
+ private void startBusyThread() {
+ Thread.ofPlatform().daemon().start(() -> {
+ double x = 0;
+ while (busy) {
+ x += Math.sqrt(x + 1);
+ }
+ });
+ }
+
+ private List rows(String file) {
+ try {
+ return Files.readAllLines(tempDir.resolve(file)).stream().skip(1).toList();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private static void await(BooleanSupplier condition) throws InterruptedException {
+ long deadline = System.nanoTime() + 10_000_000_000L;
+ while (!condition.getAsBoolean()) {
+ assertTrue(System.nanoTime() < deadline, "timed out");
+ Thread.sleep(20);
+ }
+ }
+
+ /** The source is opened and closed by the thread that reads it, so a source never has to be thread safe. */
+ @Test
+ void source_isOpenedAndClosedOnTheMonitorThread() throws Exception {
+ monitor(List.of()).start();
+ await(() -> source.reads > 0);
+
+ monitor.stop();
+
+ assertFalse(monitor.isAlive());
+ assertEquals(Monitor.THREAD_NAME, source.openedOn);
+ assertEquals(Monitor.THREAD_NAME, source.closedOn);
+ }
+
+ @Test
+ void busyThread_getsRowsInBothFiles() throws Exception {
+ startBusyThread();
+ monitor(List.of(MonitorTest.class.getName())).start();
+
+ await(() -> !rows(ResultWriter.APP_METHODS_FILE).isEmpty());
+
+ assertTrue(rows(ResultWriter.APP_METHODS_FILE).stream().anyMatch(row -> row.contains("MonitorTest.lambda$")));
+ assertFalse(rows(ResultWriter.ALL_METHODS_FILE).isEmpty());
+ }
+
+ /** Without a reading there is no energy to share: nothing is written, and the loss is logged once. */
+ @Test
+ void failingSource_writesNothingAndWarnsOnce() throws Exception {
+ source.failing = true;
+ startBusyThread();
+ monitor(List.of()).start();
+
+ await(() -> source.reads >= 3);
+ monitor.stop();
+
+ assertTrue(rows(ResultWriter.ALL_METHODS_FILE).isEmpty());
+ assertEquals(1, logs.stream().filter(record -> record.getLevel() == Level.WARNING
+ && record.getMessage().contains("the fake source is failing")).count());
+ }
+
+ /** A source that cannot be opened ends monitoring with its reason, and nothing else. */
+ @Test
+ void openFailure_isLoggedAndMonitoringEnds() throws Exception {
+ Config config = new Config("auto", tempDir.resolve("ring"), null, "powerjoular", 10, tempDir, List.of());
+ monitor = new Monitor(config, () -> {
+ throw new IOException("No power here.");
+ });
+ monitor.start();
+
+ await(() -> !monitor.isAlive());
+
+ assertTrue(logs.stream().anyMatch(record -> record.getLevel() == Level.SEVERE
+ && record.getMessage().contains("No power here.")));
+ }
+}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java
deleted file mode 100644
index 81c761f..0000000
--- a/src/test/java/org/noureddine/joular/joularcodejava/agent/MonitoringHandlerTest.java
+++ /dev/null
@@ -1,356 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent;
-
-import com.sun.management.OperatingSystemMXBean;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.junit.jupiter.api.io.TempDir;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.noureddine.joular.joularcodejava.agent.source.PowerSource;
-import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties;
-
-import java.lang.management.ManagementFactory;
-import java.lang.management.ThreadMXBean;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Properties;
-import java.util.logging.Handler;
-import java.util.logging.Level;
-import java.util.logging.LogRecord;
-import java.util.logging.Logger;
-
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.Mockito.*;
-
-/**
- * Unit tests for {@link MonitoringHandler}.
- *
- * These tests focus on the startup lifecycle and thread-management API rather
- * than the energy-attribution algorithm, which depends on live stack traces and
- * CPU time measurements that cannot be made deterministic in a unit-test context.
- *
- *
What is tested
- *
- * - Startup success path: {@code awaitStartup} returns {@code true} and
- * {@code isStartupSuccessful} returns {@code true}.
- * - Startup failure paths: a throwing {@link PowerSource#initialize()} and an
- * unwritable results directory both release the startup latch with a failure
- * flag set.
- * - Thread lifecycle: {@code stop()} + {@code joinWithTimeout()} reliably
- * terminates the monitoring thread.
- * - Pre-start guards: {@code stop()}, {@code joinWithTimeout()},
- * {@code isStartupSuccessful()}, and {@code getStartupFailure()} all behave
- * correctly when called before the thread has been started.
- *
- *
- * JDK MX bean strategy
- * The {@link ThreadMXBean} and {@link OperatingSystemMXBean} interfaces belong
- * to closed JDK modules. Byte Buddy (used by Mockito) cannot instrument them
- * without special JVM flags on recent JDK versions. To avoid this limitation, the
- * tests obtain real singleton beans from {@link ManagementFactory}. Only
- * {@link PowerSource} — a project-defined interface — is mocked.
- *
- *
Test isolation
- * {@code @AfterEach} stops and joins any running handler thread and clears the
- * {@code joularcodejava.properties} system property so tests cannot interfere
- * with each other.
- */
-@ExtendWith(MockitoExtension.class)
-class MonitoringHandlerTest {
-
- /** Mocked power source — controls initialize() and getCurrentPower() behaviour. */
- @Mock
- PowerSource mockPowerSource;
-
- /**
- * Real JDK thread bean. Cannot be mocked without Byte Buddy experimental mode
- * due to module encapsulation in JDK 17+.
- */
- private final ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
-
- /**
- * Real JDK OS bean (com.sun.management flavour required by MonitoringHandler).
- * Same module-encapsulation constraint as ThreadMXBean.
- */
- private final OperatingSystemMXBean osBean =
- ManagementFactory.getPlatformMXBean(OperatingSystemMXBean.class);
-
- @TempDir
- Path tempDir;
-
- private MonitoringHandler handler;
- private Thread handlerThread;
-
- @AfterEach
- void tearDown() {
- System.clearProperty("joularcodejava.properties");
- if (handler != null) {
- handler.stop();
- handler.joinWithTimeout(2000);
- }
- }
-
- /**
- * Writes a minimal properties file to the temp directory and constructs a
- * {@link MonitoringHandler} using the provided results path.
- * A 100 ms sample rate is used so the monitoring loop cycles quickly and
- * tests remain fast.
- *
- *
{@link Properties#store} is used instead of plain string concatenation so
- * that Windows paths with backslashes are properly double-escaped (e.g.,
- * {@code C:\tmp} → {@code C:\\tmp}). Without this, {@link Properties#load}
- * would silently consume the backslashes as escape-sequence prefixes and
- * resolve an entirely different path, causing the unwritable-path test to
- * pass startup checks on Windows when it should fail.
- */
- private MonitoringHandler makeHandler(String resultsPath) throws Exception {
- Path propsFile = tempDir.resolve("joularcodejava.properties");
- Properties props = new Properties();
- props.setProperty("results-path", resultsPath);
- props.setProperty("stack-monitoring-sample-rate", "100");
- try (var w = Files.newBufferedWriter(propsFile, StandardCharsets.UTF_8)) {
- props.store(w, null);
- }
- System.setProperty("joularcodejava.properties", propsFile.toString());
- AgentProperties agentProps = new AgentProperties();
- return new MonitoringHandler(agentProps, mockPowerSource, threadBean, osBean);
- }
-
- /**
- * Starts the handler on a daemon thread so that a test failure can never
- * prevent the JVM from exiting.
- */
- private void startHandler() {
- handlerThread = new Thread(handler, "test-monitoring");
- handlerThread.setDaemon(true);
- handlerThread.start();
- }
-
- // -------------------------------------------------------------------------
- // Startup lifecycle
- // -------------------------------------------------------------------------
-
- /**
- * Under normal conditions (writable results directory, no-throw
- * {@code PowerSource.initialize()}), the startup latch must be released
- * within the timeout, {@code isStartupSuccessful()} must return {@code true},
- * and no failure must be recorded.
- */
- @Test
- void awaitStartup_successfulInit_latchReleasedAndSuccessful() throws Exception {
- handler = makeHandler(tempDir.toString());
- startHandler();
-
- boolean settled = handler.awaitStartup(3000);
- assertTrue(settled, "awaitStartup should return true");
- assertTrue(handler.isStartupSuccessful(), "Startup should be successful");
- assertNull(handler.getStartupFailure());
- }
-
- /**
- * When {@code PowerSource.initialize()} throws, the handler must:
- *
- * - release the startup latch (so {@code awaitStartup} does not hang),
- * - record {@code isStartupSuccessful() == false},
- * - expose the thrown exception via {@code getStartupFailure()}.
- *
- * The monitoring thread exits on its own after the failure; no explicit
- * {@code stop()} call is needed (but the teardown calls it anyway as a guard).
- */
- @Test
- void awaitStartup_powerSourceThrows_latchReleasedWithFailure() throws Exception {
- doThrow(new RuntimeException("init failed")).when(mockPowerSource).initialize();
-
- handler = makeHandler(tempDir.toString());
- Logger logger = Logger.getLogger(MonitoringHandler.class.getName());
- Level oldLevel = logger.getLevel();
- logger.setLevel(Level.OFF);
- boolean settled;
- try {
- startHandler();
- settled = handler.awaitStartup(3000);
- } finally {
- logger.setLevel(oldLevel);
- }
- assertTrue(settled);
- assertFalse(handler.isStartupSuccessful());
- assertNotNull(handler.getStartupFailure());
- assertTrue(handler.getStartupFailure().getMessage().contains("init failed"));
- }
-
- /**
- * When the configured results path is an existing regular file rather than
- * a directory, {@code ResultWriter.verifyWritable()} fails with an
- * {@link java.io.IOException}. The handler must propagate this as a startup
- * failure so the agent aborts with a clear log message rather than silently
- * dropping all results.
- */
- @Test
- void awaitStartup_unwritableResultsPath_failsFast() throws Exception {
- // A regular file at the path prevents directory creation.
- Path blockingFile = tempDir.resolve("blocked");
- Files.writeString(blockingFile, "not a directory", StandardCharsets.UTF_8);
-
- handler = makeHandler(blockingFile.toString());
- Logger logger = Logger.getLogger(MonitoringHandler.class.getName());
- Level oldLevel = logger.getLevel();
- logger.setLevel(Level.OFF);
- boolean settled;
- try {
- startHandler();
- settled = handler.awaitStartup(3000);
- } finally {
- logger.setLevel(oldLevel);
- }
- assertTrue(settled);
- assertFalse(handler.isStartupSuccessful());
- assertNotNull(handler.getStartupFailure());
- assertInstanceOf(java.io.IOException.class, handler.getStartupFailure());
- }
-
- /**
- * After a successful startup, {@code stop()} must interrupt the monitoring
- * thread and {@code joinWithTimeout()} must return within the timeout,
- * confirming the thread has terminated and will not prevent JVM shutdown.
- */
- @Test
- void stop_afterSuccessfulStartup_threadTerminates() throws Exception {
- handler = makeHandler(tempDir.toString());
- startHandler();
-
- handler.awaitStartup(3000);
- assertTrue(handler.isStartupSuccessful());
-
- handler.stop();
- handler.joinWithTimeout(3000);
-
- assertFalse(handlerThread.isAlive(), "Monitoring thread should have terminated");
- }
-
- // -------------------------------------------------------------------------
- // Pre-start state
- // -------------------------------------------------------------------------
-
- /**
- * Before the monitoring thread is started, the startup latch has not been
- * counted down yet, so {@code isStartupSuccessful()} must return its initial
- * value of {@code false}.
- */
- @Test
- void isStartupSuccessful_beforeStart_returnsFalse() throws Exception {
- handler = makeHandler(tempDir.toString());
- assertFalse(handler.isStartupSuccessful());
- }
-
- /**
- * Before the monitoring thread is started, no failure has occurred, so
- * {@code getStartupFailure()} must return {@code null}.
- */
- @Test
- void getStartupFailure_beforeStart_returnsNull() throws Exception {
- handler = makeHandler(tempDir.toString());
- assertNull(handler.getStartupFailure());
- }
-
- /**
- * {@code stop()} guards against a null {@code monitoringThread} field
- * (set lazily when the thread starts). Calling it before the thread has
- * been started must not throw a {@code NullPointerException}.
- */
- @Test
- void stop_beforeThreadStarted_doesNotThrow() throws Exception {
- handler = makeHandler(tempDir.toString());
- assertDoesNotThrow(() -> handler.stop());
- }
-
- /**
- * {@code joinWithTimeout()} guards against a null {@code monitoringThread}
- * field and must return immediately (within well under 1 second) rather
- * than blocking when no thread has been started.
- */
- @Test
- void joinWithTimeout_beforeThreadStarted_returnsImmediately() throws Exception {
- handler = makeHandler(tempDir.toString());
- long start = System.currentTimeMillis();
- handler.joinWithTimeout(100);
- long elapsed = System.currentTimeMillis() - start;
- assertTrue(elapsed < 1000, "joinWithTimeout should return quickly when thread not started");
- }
-
- @Test
- void constructor_lowSampleRate_logsWarning() throws Exception {
- Path propsFile = tempDir.resolve("joularcodejava.properties");
- Properties props = new Properties();
- props.setProperty("results-path", tempDir.toString());
- props.setProperty("stack-monitoring-sample-rate", "1");
- try (var w = Files.newBufferedWriter(propsFile, StandardCharsets.UTF_8)) {
- props.store(w, null);
- }
- System.setProperty("joularcodejava.properties", propsFile.toString());
-
- Logger logger = Logger.getLogger(MonitoringHandler.class.getName());
- List records = new ArrayList<>();
- Handler capture = new Handler() {
- @Override
- public void publish(LogRecord record) {
- records.add(record);
- }
-
- @Override
- public void flush() {}
-
- @Override
- public void close() {}
- };
- logger.addHandler(capture);
- try {
- handler = new MonitoringHandler(new AgentProperties(), mockPowerSource, threadBean, osBean);
- } finally {
- logger.removeHandler(capture);
- }
-
- assertTrue(records.stream()
- .anyMatch(record -> record.getMessage().contains("stack-monitoring-sample-rate")));
- }
-
- // -------------------------------------------------------------------------
- // Power attribution
- // -------------------------------------------------------------------------
-
- @Test
- void calculateThreadPower_withCpuDeltas_usesCpuTimeProportions() {
- Map power = MonitoringHandler.calculateThreadPower(
- Map.of(1L, 25L, 2L, 75L),
- 100.0,
- 40.0);
-
- assertEquals(10.0, power.get(1L), 1e-9);
- assertEquals(30.0, power.get(2L), 1e-9);
- }
-
- @Test
- void calculateThreadPower_withoutCpuDeltas_returnsEmptyMap() {
- Map power = MonitoringHandler.calculateThreadPower(
- Map.of(),
- 0.0,
- 80.0);
-
- assertTrue(power.isEmpty());
- }
-}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBufferTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBufferTest.java
new file mode 100644
index 0000000..b5c7fc1
--- /dev/null
+++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerJoularRingBufferTest.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.noureddine.joular.joularcodejava.agent.PowerJoularRingBuffer.ENTRY_SIZE;
+import static org.noureddine.joular.joularcodejava.agent.PowerJoularRingBuffer.FILE_SIZE;
+import static org.noureddine.joular.joularcodejava.agent.PowerJoularRingBuffer.MAX_WINDOW_NS;
+import static org.noureddine.joular.joularcodejava.agent.PowerSource.ONE_SECOND_NS;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.CleanupMode;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+/**
+ * The ring buffer read from files laid out the way PowerJoular writes them with {@code -r}. Entries carry the
+ * current time unless a test says otherwise, since old entries mean PowerJoular has stopped.
+ */
+class PowerJoularRingBufferTest {
+
+ // A mapping is only released by the garbage collector, and Windows cannot delete a file while it is mapped
+ @TempDir(cleanup = CleanupMode.NEVER)
+ Path tempDir;
+
+ private Path ring() {
+ return tempDir.resolve("powerjoular");
+ }
+
+ private static long now() {
+ return System.currentTimeMillis() / 1000L;
+ }
+
+ /** Writes an area whose newest entry, {@code (head - 1) mod 5}, holds the given power. */
+ private Path write(long head, double cpuPower, long timestamp) throws IOException {
+ ByteBuffer area = ByteBuffer.allocate(FILE_SIZE).order(ByteOrder.nativeOrder());
+ area.putLong(0, head);
+ int entry = 8 + (int) Math.floorMod(head - 1, 5L) * ENTRY_SIZE;
+ area.putLong(entry, timestamp);
+ area.putDouble(entry + 8, cpuPower);
+ // Written in place, the way PowerJoular updates it: Windows refuses to truncate a mapped file
+ return Files.write(ring(), area.array(), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
+ }
+
+ private Path write(long head, double cpuPower) throws IOException {
+ return write(head, cpuPower, now());
+ }
+
+ /** Opens the source on an area at cycle {@code head - 1}, so that writing cycle {@code head} ends the window. */
+ private PowerJoularRingBuffer openBefore(long head) throws IOException {
+ write(head - 1, 1.0);
+ return PowerJoularRingBuffer.open(ring());
+ }
+
+ // -------------------------------------------------------------------------
+ // Reading
+ // -------------------------------------------------------------------------
+
+ @ParameterizedTest(name = "head={0} -> {1} W")
+ @CsvSource({"1, 25.0", "3, 18.0", "7, 11.5"})
+ void watts_readsTheNewestEntry(long head, double power) throws IOException {
+ try (PowerJoularRingBuffer ring = openBefore(head)) {
+ write(head, power);
+ assertEquals(power, ring.watts(), 1e-9);
+ }
+ }
+
+ /** No cycle written yet, the -1 PowerJoular writes for what it could not measure, and a value that is no power. */
+ @ParameterizedTest(name = "head={0} power={1}")
+ @CsvSource({"0, 10.0", "-1, 10.0", "1, -1.0", "1, NaN"})
+ void watts_nothingUsable_throws(long head, double power) throws IOException {
+ try (PowerJoularRingBuffer ring = openBefore(head)) {
+ write(head, power);
+ assertThrows(IOException.class, ring::watts);
+ }
+ }
+
+ /** The area outlives PowerJoular, so it is the timestamp that tells a stopped one. A step back in time is not age. */
+ @ParameterizedTest(name = "entry {0} s old -> read: {1}")
+ @CsvSource({"2, true", "-60, true", "3600, false"})
+ void watts_judgesFreshnessOnTheTimestamp(long ageSeconds, boolean fresh) throws IOException {
+ try (PowerJoularRingBuffer ring = openBefore(2)) {
+ write(2, 16.0, now() - ageSeconds);
+ if (fresh) {
+ assertEquals(16.0, ring.watts(), 1e-9);
+ } else {
+ assertThrows(IOException.class, ring::watts);
+ }
+ }
+ }
+
+ /** Each window is charged the cycle published during it, never the previous window's again. */
+ @Test
+ void watts_noNewCycleSinceTheLastReading_throwsUntilTheNextOne() throws IOException {
+ try (PowerJoularRingBuffer ring = openBefore(5)) {
+ write(5, 13.0);
+ assertEquals(13.0, ring.watts(), 1e-9);
+
+ assertThrows(IOException.class, ring::watts, "PowerJoular published nothing since");
+ assertThrows(IOException.class, ring::watts, "mapped again, the same entry is not charged again");
+
+ write(6, 14.0);
+ assertEquals(14.0, ring.watts(), 1e-9);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Attaching
+ // -------------------------------------------------------------------------
+
+ /** PowerJoular may start after the application: the source opens anyway and picks the area up once it is there. */
+ @Test
+ void watts_areaAppearsLater_isPickedUp() throws IOException {
+ try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) {
+ assertThrows(IOException.class, ring::watts);
+
+ write(1, 21.0);
+ assertEquals(21.0, ring.watts(), 1e-9);
+ }
+ }
+
+ /** A file not yet sized to the area is not mapped: reading past its end would fault instead of failing. */
+ @Test
+ void watts_areaTooShort_isNotMapped() throws IOException {
+ Files.write(ring(), new byte[64]);
+ try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) {
+ assertThrows(IOException.class, ring::watts);
+
+ write(1, 27.5);
+ assertEquals(27.5, ring.watts(), 1e-9);
+ }
+ }
+
+ /**
+ * PowerJoular deletes the area and creates a new one when it restarts, which leaves the old one mapped here, no
+ * longer moving. The window without a cycle must lead to the new area, not to the old one's last value again.
+ */
+ @Test
+ @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Windows cannot delete a mapped file")
+ void watts_powerJoularRestarted_followsTheNewArea() throws IOException {
+ try (PowerJoularRingBuffer ring = openBefore(9)) {
+ Files.delete(ring());
+ write(1, 35.0);
+
+ assertThrows(IOException.class, ring::watts, "the old area published nothing");
+ assertEquals(35.0, ring.watts(), 1e-9, "the new area is read");
+ }
+ }
+
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void watts_symbolicLink_isRefused() throws IOException {
+ Path elsewhere = tempDir.resolve("elsewhere");
+ write(1, 20.0);
+ Files.move(ring(), elsewhere);
+ Files.createSymbolicLink(ring(), elsewhere);
+
+ try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) {
+ assertThrows(IOException.class, ring::watts);
+ }
+ }
+
+ /** A pipe planted at the path would block the open, so only a plain file is mapped. */
+ @Test
+ void watts_notARegularFile_isRefused() throws IOException {
+ Files.createDirectory(ring());
+
+ try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) {
+ assertThrows(IOException.class, ring::watts);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Monitoring windows: ended by PowerJoular's cycles
+ // -------------------------------------------------------------------------
+
+ /** Without an area there is no cadence to follow, so windows last a second. */
+ @Test
+ void windowOver_notAttached_afterOneSecond() {
+ try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) {
+ assertFalse(ring.windowOver(ONE_SECOND_NS - 1));
+ assertTrue(ring.windowOver(ONE_SECOND_NS));
+ }
+ }
+
+ /** The window waits for PowerJoular's next cycle, past a second if need be, and ends as soon as it is published. */
+ @Test
+ void windowOver_endsWhenPowerJoularPublishes() throws IOException {
+ write(4, 12.0);
+ try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) {
+ assertFalse(ring.windowOver(ONE_SECOND_NS), "no new cycle yet");
+
+ write(5, 13.0);
+ assertTrue(ring.windowOver(1), "a new cycle ends the window at once");
+
+ assertEquals(13.0, ring.watts(), 1e-9);
+ assertFalse(ring.windowOver(1), "the next window waits for the cycle after");
+ }
+ }
+
+ /** A PowerJoular that stopped publishing does not hold a window open forever. */
+ @Test
+ void windowOver_noNewCycle_endsAfterTwoSeconds() throws IOException {
+ write(4, 12.0);
+ try (PowerJoularRingBuffer ring = PowerJoularRingBuffer.open(ring())) {
+ assertFalse(ring.windowOver(MAX_WINDOW_NS - 1));
+ assertTrue(ring.windowOver(MAX_WINDOW_NS));
+ }
+ }
+}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerModelTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerModelTest.java
new file mode 100644
index 0000000..01fb499
--- /dev/null
+++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerModelTest.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.noureddine.joular.joularcodejava.agent.BranchTest.branch;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.noureddine.joular.joularcodejava.agent.PowerModel.CpuUsage;
+
+class PowerModelTest {
+
+ private static final long SECOND = 1_000_000_000L;
+
+ // -------------------------------------------------------------------------
+ // processShare: the JVM's part of the machine's CPU power
+ // -------------------------------------------------------------------------
+
+ /** Half of one core out of four is 12.5% of the machine; with the machine at 25%, the JVM is half of it. */
+ @Test
+ void processShare_isTheJvmsLoadOverTheMachinesLoad() {
+ assertEquals(0.5, PowerModel.processShare(SECOND / 2, SECOND, 4, 0.25), 1e-9);
+ }
+
+ /** The two loads are not taken at the exact same instant, so the ratio can go past 1. */
+ @Test
+ void processShare_neverAboveOne() {
+ assertEquals(1.0, PowerModel.processShare(SECOND, SECOND, 4, 0.1), 1e-9);
+ }
+
+ /** Without the machine's load, it is taken as fully loaded, which under-states the JVM's share. */
+ @Test
+ void processShare_unknownSystemLoad_fallsBackToTheJvmsLoad() {
+ assertEquals(0.25, PowerModel.processShare(SECOND, SECOND, 4, -1.0), 1e-9);
+ assertEquals(0.25, PowerModel.processShare(SECOND, SECOND, 4, Double.NaN), 1e-9);
+ assertEquals(0.25, PowerModel.processShare(SECOND, SECOND, 4, 0.0), 1e-9);
+ }
+
+ @Test
+ void processShare_nothingToDivide_isZero() {
+ assertEquals(0.0, PowerModel.processShare(0, SECOND, 4, 0.5));
+ assertEquals(0.0, PowerModel.processShare(SECOND, 0, 4, 0.5));
+ assertEquals(0.0, PowerModel.processShare(SECOND, SECOND, 0, 0.5));
+ }
+
+ // -------------------------------------------------------------------------
+ // cpuUsage: which CPU time goes in the denominator, and what coverage says
+ // -------------------------------------------------------------------------
+
+ /** A thread that used CPU without being sampled still counts in the total, so the sampled ones are not overpaid. */
+ @Test
+ void cpuUsage_unsampledThreadStaysInTheTotal() {
+ CpuUsage usage = PowerModel.cpuUsage(Set.of(1L), Map.of(1L, 0L, 2L, 0L), Map.of(1L, 300L, 2L, 700L));
+
+ assertEquals(1000L, usage.totalNs(), "both threads' CPU time counts");
+ assertEquals(Map.of(1L, 300L), usage.sampledThreadsNs(), "only the sampled one is paid");
+ assertEquals(0.3, usage.coverage(), 1e-9);
+ }
+
+ /** A thread missing from the start snapshot began inside the window, so all its CPU time was spent there. */
+ @Test
+ void cpuUsage_threadStartedMidWindow_countsAllOfItsCpuTime() {
+ CpuUsage usage = PowerModel.cpuUsage(Set.of(7L), Map.of(), Map.of(7L, 500L));
+
+ assertEquals(500L, usage.totalNs());
+ assertEquals(1.0, usage.coverage(), 1e-9);
+ }
+
+ @Test
+ void cpuUsage_idleThreadIgnored() {
+ CpuUsage usage = PowerModel.cpuUsage(Set.of(1L), Map.of(1L, 100L, 2L, 900L), Map.of(1L, 600L, 2L, 900L));
+
+ assertEquals(500L, usage.totalNs());
+ assertEquals(1.0, usage.coverage(), 1e-9, "the idle thread is not missing coverage");
+ }
+
+ @Test
+ void cpuUsage_noCpuUsed_coverageIsZero() {
+ CpuUsage usage = PowerModel.cpuUsage(Set.of(1L), Map.of(1L, 100L), Map.of(1L, 100L));
+
+ assertEquals(0L, usage.totalNs());
+ assertEquals(0.0, usage.coverage(), 1e-9);
+ assertTrue(usage.sampledThreadsNs().isEmpty());
+ }
+
+ // -------------------------------------------------------------------------
+ // threadPower
+ // -------------------------------------------------------------------------
+
+ @Test
+ void threadPower_splitsByCpuTime() {
+ Map power = PowerModel.threadPower(new CpuUsage(Map.of(1L, 25L, 2L, 75L), 100L, 1.0), 40.0);
+
+ assertEquals(10.0, power.get(1L), 1e-9);
+ assertEquals(30.0, power.get(2L), 1e-9);
+ }
+
+ /**
+ * What is handed out is less than the JVM drew, by exactly the share never observed. Normalising over the
+ * sampled threads instead would balance the totals by inflating the methods that happened to be seen.
+ */
+ @Test
+ void threadPower_unobservedShareIsLeftUnattributed() {
+ CpuUsage usage = PowerModel.cpuUsage(Set.of(1L), Map.of(1L, 0L, 2L, 0L), Map.of(1L, 250L, 2L, 750L));
+
+ Map power = PowerModel.threadPower(usage, 80.0);
+
+ assertEquals(20.0, power.get(1L), 1e-9);
+ assertEquals(usage.coverage() * 80.0, power.values().stream().mapToDouble(Double::doubleValue).sum(), 1e-9);
+ }
+
+ @Test
+ void threadPower_noPowerOrNoCpu_isEmpty() {
+ assertTrue(PowerModel.threadPower(new CpuUsage(Map.of(), 0L, 0.0), 80.0).isEmpty());
+ assertTrue(PowerModel.threadPower(new CpuUsage(Map.of(1L, 10L), 10L, 1.0), 0.0).isEmpty());
+ }
+
+ // -------------------------------------------------------------------------
+ // branchPower
+ // -------------------------------------------------------------------------
+
+ @Test
+ void branchPower_splitsEachThreadBySampleCount() {
+ Map power = PowerModel.branchPower(
+ Map.of(1L, Map.of(branch("a.A.a"), 3, branch("b.B.b"), 1)), Map.of(1L, 8.0), Branch::name);
+
+ assertEquals(6.0, power.get("a.A.a"), 1e-9);
+ assertEquals(2.0, power.get("b.B.b"), 1e-9);
+ }
+
+ @Test
+ void branchPower_sameBranchOnTwoThreads_isSummed() {
+ Map power = PowerModel.branchPower(
+ Map.of(1L, Map.of(branch("a.A.a"), 1), 2L, Map.of(branch("a.A.a"), 1)),
+ Map.of(1L, 2.0, 2L, 3.0), Branch::name);
+
+ assertEquals(5.0, power.get("a.A.a"), 1e-9);
+ }
+
+ /** Samples that matched no app frame still count for the thread, so their share is not given to the app branches. */
+ @Test
+ void branchPower_unmatchedSamplesStayInTheThreadTotal() {
+ List prefixes = List.of("com.app");
+ Map power = PowerModel.branchPower(
+ Map.of(1L, Map.of(branch("com.app.Main.run"), 1, branch("java.lang.Thread.run"), 3)),
+ Map.of(1L, 8.0), branch -> branch.appName(prefixes));
+
+ assertEquals(Map.of("com.app.Main.run", 2.0), power);
+ }
+
+ @Test
+ void branchPower_threadWithoutPower_isSkipped() {
+ assertTrue(PowerModel.branchPower(Map.of(1L, Map.of(branch("a.A.a"), 1)), Map.of(), Branch::name).isEmpty());
+ }
+}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerSourceTest.java
new file mode 100644
index 0000000..0df11b2
--- /dev/null
+++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/PowerSourceTest.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeFalse;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Which source {@code power-source-type} opens. */
+class PowerSourceTest {
+
+ @TempDir
+ Path tempDir;
+
+ private Config config(String type, Path vmPowerFile, String vmPowerFormat) {
+ return new Config(type, tempDir.resolve("ring"), vmPowerFile, vmPowerFormat, 10, tempDir, List.of());
+ }
+
+ private Config config(String type) {
+ return config(type, null, "powerjoular");
+ }
+
+ @Test
+ void unknownType_throwsNamingTheChoices() {
+ IOException e = assertThrows(IOException.class, () -> PowerSource.open(config("joularcore")));
+ assertTrue(e.getMessage().contains("auto, rapl, ringbuffer or vm"), e.getMessage());
+ }
+
+ /** Without RAPL, the CPU power comes from PowerJoular, which may start after the application. */
+ @Test
+ void auto_withoutRapl_opensTheRingBuffer() throws IOException {
+ assumeFalse(LinuxRapl.isPresent());
+
+ try (PowerSource source = PowerSource.open(config("auto"))) {
+ assertInstanceOf(PowerJoularRingBuffer.class, source);
+ }
+ }
+
+ @Test
+ void ringbuffer_opensEvenWithoutPowerJoular() throws IOException {
+ try (PowerSource source = PowerSource.open(config("ringbuffer"))) {
+ assertInstanceOf(PowerJoularRingBuffer.class, source);
+ }
+ }
+
+ @Test
+ @DisabledOnOs(OS.LINUX)
+ void rapl_outsideLinux_throws() {
+ assertThrows(IOException.class, () -> PowerSource.open(config("rapl")));
+ }
+
+ /** The host may start writing the file after the application, so it does not have to be there yet. */
+ @Test
+ void vm_opensEvenBeforeTheHostWrites() throws IOException {
+ try (PowerSource source = PowerSource.open(config("vm", tempDir.resolve("vm.csv"), "powerjoular"))) {
+ assertInstanceOf(VmPowerFile.class, source);
+ }
+ }
+
+ @Test
+ void vm_withoutAFile_throwsNamingTheSetting() {
+ IOException e = assertThrows(IOException.class, () -> PowerSource.open(config("vm")));
+ assertTrue(e.getMessage().contains("vm-power-file"), e.getMessage());
+ }
+
+ @Test
+ void vm_unknownFormat_throwsNamingTheChoices() {
+ IOException e = assertThrows(IOException.class,
+ () -> PowerSource.open(config("vm", tempDir.resolve("vm.csv"), "json")));
+ assertTrue(e.getMessage().contains("powerjoular or watts"), e.getMessage());
+ }
+}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/ResultWriterTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/ResultWriterTest.java
new file mode 100644
index 0000000..f24d2de
--- /dev/null
+++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/ResultWriterTest.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.noureddine.joular.joularcodejava.agent.ResultWriter.ALL_METHODS_FILE;
+import static org.noureddine.joular.joularcodejava.agent.ResultWriter.APP_METHODS_FILE;
+import static org.noureddine.joular.joularcodejava.agent.ResultWriter.CSV_HEADER;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+import org.junit.jupiter.api.io.TempDir;
+
+class ResultWriterTest {
+
+ @TempDir
+ Path tempDir;
+
+ private List lines(String file) throws IOException {
+ return Files.readAllLines(tempDir.resolve(file), StandardCharsets.UTF_8);
+ }
+
+ private void writeAll(Map power, double intervalSeconds) throws IOException {
+ try (ResultWriter writer = new ResultWriter(tempDir)) {
+ writer.write(power, Map.of(), 1000L, intervalSeconds, 1.0);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Opening
+ // -------------------------------------------------------------------------
+
+ @Test
+ void open_createsTheDirectoryAndBothFilesWithAHeader() throws IOException {
+ Path nested = tempDir.resolve("sub/nested");
+ new ResultWriter(nested).close();
+
+ assertEquals(List.of(CSV_HEADER), Files.readAllLines(nested.resolve(ALL_METHODS_FILE)));
+ assertEquals(List.of(CSV_HEADER), Files.readAllLines(nested.resolve(APP_METHODS_FILE)));
+ }
+
+ /** A regular file where the directory should be fails at startup, rather than dropping every result later. */
+ @Test
+ void open_pathIsAFile_throws() throws IOException {
+ Path blockingFile = tempDir.resolve("blocked");
+ Files.writeString(blockingFile, "not a directory");
+
+ assertThrows(IOException.class, () -> new ResultWriter(blockingFile));
+ }
+
+ /** A link planted where a results file goes must not make the agent, perhaps running as root, write through it. */
+ @Test
+ @DisabledOnOs(OS.WINDOWS)
+ void open_symbolicLink_isRefused() throws IOException {
+ Path target = tempDir.resolve("target.txt");
+ Files.writeString(target, "precious");
+ Files.createSymbolicLink(tempDir.resolve(ALL_METHODS_FILE), target);
+
+ assertThrows(IOException.class, () -> new ResultWriter(tempDir));
+ assertEquals("precious", Files.readString(target));
+ }
+
+ /** Results already there are kept, and the header is not written twice. */
+ @Test
+ void open_existingResults_appendsWithoutASecondHeader() throws IOException {
+ writeAll(Map.of("A.m", 1.0), 1.0);
+ writeAll(Map.of("B.m", 2.0), 1.0);
+
+ List lines = lines(ALL_METHODS_FILE);
+ assertEquals(3, lines.size());
+ assertEquals(1, lines.stream().filter(CSV_HEADER::equals).count());
+ }
+
+ // -------------------------------------------------------------------------
+ // Rows
+ // -------------------------------------------------------------------------
+
+ @Test
+ void write_eachFileGetsItsOwnRows() throws IOException {
+ try (ResultWriter writer = new ResultWriter(tempDir)) {
+ writer.write(Map.of("java.lang.Thread.run;com.app.Main.run", 3.0), Map.of("com.app.Main.run", 3.0),
+ 1746000000000L, 1.0, 0.75);
+ }
+
+ assertEquals("1746000000000,java.lang.Thread.run;com.app.Main.run,3.000000000,3.000000000,1.000000000,0.7500",
+ lines(ALL_METHODS_FILE).get(1));
+ assertEquals("1746000000000,com.app.Main.run,3.000000000,3.000000000,1.000000000,0.7500",
+ lines(APP_METHODS_FILE).get(1));
+ }
+
+ @Test
+ void write_energyIsPowerTimesInterval() throws IOException {
+ writeAll(Map.of("m.m", 4.0), 2.5);
+
+ String[] columns = lines(ALL_METHODS_FILE).get(1).split(",");
+ assertEquals(10.0, Double.parseDouble(columns[3]), 1e-9);
+ assertEquals(2.5, Double.parseDouble(columns[4]), 1e-9);
+ }
+
+ @Test
+ void write_zeroOrNegativePower_rowSkipped() throws IOException {
+ Map power = new LinkedHashMap<>();
+ power.put("A.zero", 0.0);
+ power.put("B.negative", -1.0);
+ power.put("C.positive", 3.0);
+ writeAll(power, 1.0);
+
+ List lines = lines(ALL_METHODS_FILE);
+ assertEquals(2, lines.size());
+ assertTrue(lines.get(1).contains("C.positive"));
+ }
+
+ @Test
+ void write_branchWithAComma_isQuoted() throws IOException {
+ writeAll(Map.of("foo,bar", 3.0), 1.0);
+
+ assertTrue(lines(ALL_METHODS_FILE).get(1).contains("\"foo,bar\""));
+ }
+
+ @Test
+ void close_isIdempotent() throws IOException {
+ ResultWriter writer = new ResultWriter(tempDir);
+ assertDoesNotThrow(() -> {
+ writer.close();
+ writer.close();
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // csvEscape
+ // -------------------------------------------------------------------------
+
+ @Test
+ void csvEscape() {
+ assertEquals("org.example.Foo.bar", ResultWriter.csvEscape("org.example.Foo.bar"));
+ assertEquals("", ResultWriter.csvEscape(""));
+ assertEquals("\"a,b\"", ResultWriter.csvEscape("a,b"));
+ assertEquals("\"foo\"\"bar\"", ResultWriter.csvEscape("foo\"bar"));
+ assertEquals("\"a\nb\"", ResultWriter.csvEscape("a\nb"));
+ assertEquals("\"a\rb\"", ResultWriter.csvEscape("a\rb"));
+ assertEquals("\"a,\"\"b\"\"\"", ResultWriter.csvEscape("a,\"b\""));
+ }
+}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/ThreadSamplerTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/ThreadSamplerTest.java
new file mode 100644
index 0000000..e8a500b
--- /dev/null
+++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/ThreadSamplerTest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.sun.management.ThreadMXBean;
+import java.lang.management.ManagementFactory;
+import org.junit.jupiter.api.Test;
+import org.noureddine.joular.joularcodejava.agent.ThreadSampler.Samples;
+
+/** Sampling this very JVM, whose test thread is running while it is sampled. */
+class ThreadSamplerTest {
+
+ private final ThreadMXBean threadBean = (ThreadMXBean) ManagementFactory.getThreadMXBean();
+
+ @Test
+ void sample_recordsTheRunningTestThread() {
+ ThreadSampler sampler = new ThreadSampler(threadBean, -1);
+ Samples samples = new Samples();
+
+ sampler.sample(samples);
+
+ assertEquals(1, samples.taken);
+ assertTrue(samples.branches.containsKey(Thread.currentThread().threadId()));
+ assertTrue(sampler.cpuTimes().containsKey(Thread.currentThread().threadId()));
+ }
+
+ /** The agent's own thread is never measured, so it is left out of both the samples and the CPU times. */
+ @Test
+ void ownThread_isLeftOut() {
+ long self = Thread.currentThread().threadId();
+ ThreadSampler sampler = new ThreadSampler(threadBean, self);
+ Samples samples = new Samples();
+
+ sampler.sample(samples);
+
+ assertFalse(samples.branches.containsKey(self));
+ assertFalse(sampler.cpuTimes().containsKey(self));
+ assertTrue(sampler.ownCpuTime() >= 0, "its time is read apart");
+ }
+}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/VmPowerFileTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/VmPowerFileTest.java
new file mode 100644
index 0000000..2ac1d92
--- /dev/null
+++ b/src/test/java/org/noureddine/joular/joularcodejava/agent/VmPowerFileTest.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (c) 2026, Adel Noureddine.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the
+ * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
+ * which accompanies this distribution, and is available at
+ * https://www.gnu.org/licenses/lgpl-3.0.en.html
+ *
+ * Author : Adel Noureddine
+ */
+
+package org.noureddine.joular.joularcodejava.agent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.noureddine.joular.joularcodejava.agent.VmPowerFile.MAX_REUSES;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** The file a host writes the power of a virtual machine to, as the guest reads it. */
+class VmPowerFileTest {
+
+ @TempDir
+ Path tempDir;
+
+ private Path file() {
+ return tempDir.resolve("vm-power.csv");
+ }
+
+ private void write(String content) throws IOException {
+ Files.writeString(file(), content);
+ }
+
+ /** The row PowerJoular rewrites every second with -o for the process a virtual machine runs as. */
+ @Nested
+ class PowerJoularFormat {
+
+ private VmPowerFile open() throws IOException {
+ return VmPowerFile.open(file(), "powerjoular");
+ }
+
+ @Test
+ void readsThePowerColumn() throws IOException {
+ write("1790000000,0.2500,12.5000\n");
+ assertEquals(12.5, open().watts(), 1e-9);
+ }
+
+ /** The host may run Windows, and only the first line is the latest row. */
+ @Test
+ void windowsLineEndingsAndFurtherLines_areFine() throws IOException {
+ write("1790000000,0.25,12.5\r\nsomething else\r\n");
+ assertEquals(12.5, open().watts(), 1e-9);
+ }
+
+ /** A row caught while the host writes it may be cut inside the number, which would still parse. */
+ @Test
+ void rowWithoutItsNewline_isNotRead() throws IOException {
+ write("1790000000,0.25,12.5");
+ assertThrows(IOException.class, open()::watts);
+ }
+
+ @Test
+ void wholeHostFile_isRefused() throws IOException {
+ write("1790000000,0.25,40.0,35.0,5.0\n");
+ IOException e = assertThrows(IOException.class, open()::watts);
+ assertTrue(e.getMessage().contains("whole host"), e.getMessage());
+ }
+
+ /** Written with -f instead of -o, the file starts with a header. */
+ @ParameterizedTest
+ @ValueSource(strings = {"Timestamp,CPU Usage,CPU Power\n", "1790000000,0.25,-1.0\n", "1790000000,0.25\n", "\n"})
+ void unusableRow_throws(String content) throws IOException {
+ write(content);
+ assertThrows(IOException.class, open()::watts);
+ }
+
+ @Test
+ void missingFile_throws() {
+ IOException e = assertThrows(IOException.class, () -> open().watts());
+ assertTrue(e.getMessage().contains("There is no"), e.getMessage());
+ }
+
+ @Test
+ void notARegularFile_isRefused() throws IOException {
+ Files.createDirectory(file());
+ assertThrows(IOException.class, open()::watts);
+ }
+
+ /**
+ * The host and the windows run on separate one-second cycles, so a window may see the row the previous one
+ * saw, or catch the file empty while it is rewritten. The last row covers that, for a little while.
+ */
+ @Test
+ void noNewRow_lastOneCoversAFewWindowsThenTheHostIsTakenToHaveStopped() throws IOException {
+ VmPowerFile source = open();
+ write("1790000000,0.25,12.5\n");
+ assertEquals(12.5, source.watts(), 1e-9);
+
+ write("");
+ assertEquals(12.5, source.watts(), 1e-9, "caught while the host rewrites it");
+ write("1790000000,0.25,12.5\n");
+ for (int reuse = 2; reuse <= MAX_REUSES; reuse++) {
+ assertEquals(12.5, source.watts(), 1e-9, "the same row again");
+ }
+
+ IOException e = assertThrows(IOException.class, source::watts);
+ assertTrue(e.getMessage().contains("has not updated"), e.getMessage());
+
+ write("1790000004,0.30,15.0\n");
+ assertEquals(15.0, source.watts(), 1e-9, "the host writes again");
+ }
+ }
+
+ /** A power alone, written by anything. */
+ @Nested
+ class WattsFormat {
+
+ private VmPowerFile open() throws IOException {
+ return VmPowerFile.open(file(), "watts");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"42.5\n", "42.5", " 42.5 \r\n"})
+ void readsTheValue(String content) throws IOException {
+ write(content);
+ assertEquals(42.5, open().watts(), 1e-9);
+ }
+
+ /** There is no timestamp to tell a stopped host from a steady one, so the value is used as long as it is there. */
+ @Test
+ void unchangedValue_isUsedEveryTime() throws IOException {
+ write("42.5\n");
+ VmPowerFile source = open();
+ for (int window = 0; window <= MAX_REUSES + 2; window++) {
+ assertEquals(42.5, source.watts(), 1e-9);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"-1\n", "lots\n", "NaN\n", ""})
+ void unusableValue_throws(String content) throws IOException {
+ write(content);
+ assertThrows(IOException.class, open()::watts);
+ }
+
+ @Test
+ void lineTooLong_throws() throws IOException {
+ write("4".repeat(300));
+ assertThrows(IOException.class, open()::watts);
+ }
+ }
+}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriterTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriterTest.java
deleted file mode 100644
index 9789cec..0000000
--- a/src/test/java/org/noureddine/joular/joularcodejava/agent/result/ResultWriterTest.java
+++ /dev/null
@@ -1,352 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.result;
-
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-/**
- * Unit tests for {@link ResultWriter}.
- *
- * Covers three areas:
- *
- * - {@code csvEscape} — RFC 4180 escaping of special characters in CSV field values.
- * - {@code verifyWritable} — fail-fast directory creation and write-probe behaviour.
- * - {@code writeRuntimeMethods} — CSV output format, header injection, power filtering,
- * energy calculation, and idempotent close.
- *
- *
- * Every test that touches the filesystem uses a JUnit 5 {@code @TempDir} so no
- * cleanup is needed and tests are fully isolated from each other.
- */
-class ResultWriterTest {
-
- @TempDir
- Path tempDir;
-
- // -------------------------------------------------------------------------
- // csvEscape
- // -------------------------------------------------------------------------
-
- /**
- * A {@code null} input must be treated as an empty string rather than
- * propagating a NullPointerException into the CSV output.
- */
- @Test
- void csvEscape_null_returnsEmptyString() {
- assertEquals("", ResultWriter.csvEscape(null));
- }
-
- /**
- * A value that contains none of the RFC 4180 special characters
- * (comma, double-quote, newline, carriage-return) must be returned
- * unchanged — no surrounding quotes should be added.
- */
- @Test
- void csvEscape_plainText_unchanged() {
- String input = "org.example.Foo.bar";
- assertEquals(input, ResultWriter.csvEscape(input));
- }
-
- /**
- * An empty string contains no special characters and must be returned as-is.
- */
- @Test
- void csvEscape_empty_returnsEmpty() {
- assertEquals("", ResultWriter.csvEscape(""));
- }
-
- /**
- * A comma inside a field value is a CSV delimiter and must be neutralised
- * by wrapping the entire value in double-quotes.
- */
- @Test
- void csvEscape_withComma_quotesValue() {
- assertEquals("\"a,b\"", ResultWriter.csvEscape("a,b"));
- }
-
- /**
- * A double-quote inside a field value must be escaped by doubling it
- * (RFC 4180 §2.7) and the whole value wrapped in double-quotes.
- * Input {@code foo"bar} becomes {@code "foo""bar"}.
- */
- @Test
- void csvEscape_withDoubleQuote_doublesAndQuotes() {
- assertEquals("\"foo\"\"bar\"", ResultWriter.csvEscape("foo\"bar"));
- }
-
- /**
- * A newline character inside a value causes the value to be wrapped in
- * double-quotes; the newline itself must be preserved verbatim inside the
- * quotes (not escaped or removed).
- */
- @Test
- void csvEscape_withNewline_quotesValue() {
- String result = ResultWriter.csvEscape("a\nb");
- assertTrue(result.startsWith("\"") && result.endsWith("\""), "Should be quoted: " + result);
- assertTrue(result.contains("\n"), "Newline should be preserved inside quotes");
- }
-
- /**
- * A carriage-return character must trigger quoting just as a newline does.
- */
- @Test
- void csvEscape_withCarriageReturn_quotesValue() {
- String result = ResultWriter.csvEscape("a\rb");
- assertTrue(result.startsWith("\"") && result.endsWith("\""), "Should be quoted: " + result);
- }
-
- /**
- * When a value contains both a comma and a double-quote, both escaping
- * rules apply simultaneously: the value is wrapped in double-quotes, and
- * each internal double-quote is doubled.
- * Input {@code a,"b"} becomes {@code "a,""b""}.
- */
- @Test
- void csvEscape_withCommaAndQuote_bothRulesApplied() {
- // "a,"b"" → "a,""b"""
- assertEquals("\"a,\"\"b\"\"\"", ResultWriter.csvEscape("a,\"b\""));
- }
-
- // -------------------------------------------------------------------------
- // verifyWritable
- // -------------------------------------------------------------------------
-
- /**
- * When the results directory does not yet exist, {@code verifyWritable}
- * must create it (including any missing intermediate directories) and
- * complete without throwing.
- */
- @Test
- void verifyWritable_nonExistentDir_createsDir() throws Exception {
- Path nested = tempDir.resolve("sub/nested");
- ResultWriter writer = new ResultWriter(nested.toString());
- assertDoesNotThrow(writer::verifyWritable);
- assertTrue(Files.isDirectory(nested));
- }
-
- /**
- * When the results directory already exists and is writable,
- * {@code verifyWritable} must succeed without throwing.
- */
- @Test
- void verifyWritable_existingDir_succeeds() throws Exception {
- ResultWriter writer = new ResultWriter(tempDir.toString());
- assertDoesNotThrow(writer::verifyWritable);
- }
-
- /**
- * The write-probe file ({@code .joular-write-probe}) is used only to
- * confirm the directory is writable at startup. It must be deleted
- * before {@code verifyWritable} returns so it does not pollute the
- * results directory.
- */
- @Test
- void verifyWritable_probeFileDeletedAfterCheck() throws Exception {
- ResultWriter writer = new ResultWriter(tempDir.toString());
- writer.verifyWritable();
- assertFalse(Files.exists(tempDir.resolve(".joular-write-probe")));
- }
-
- // -------------------------------------------------------------------------
- // writeRuntimeMethods
- // -------------------------------------------------------------------------
-
- /**
- * The first write to a new CSV file must produce the standard header line
- * as the very first line of the file, followed by the data row.
- * Expected header: {@code timestamp,branch,power_watts,energy_joules,interval_seconds}.
- */
- @Test
- void writeRuntimeMethods_newFile_writesHeaderThenData() throws Exception {
- ResultWriter writer = new ResultWriter(tempDir.toString());
- writer.verifyWritable();
- writer.writeRuntimeMethods(Map.of("com.A.method", 5.0), 1000L, 1.0, "out.csv");
- writer.close();
-
- List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8);
- assertEquals("timestamp,branch,power_watts,energy_joules,interval_seconds", lines.get(0));
- assertEquals(2, lines.size());
- }
-
- /**
- * A method entry with exactly zero power must not produce a CSV row.
- * Only entries with strictly positive power are meaningful for energy
- * attribution; writing zeros would inflate the output file without adding
- * information.
- */
- @Test
- void writeRuntimeMethods_zeroPower_rowSkipped() throws Exception {
- ResultWriter writer = new ResultWriter(tempDir.toString());
- writer.verifyWritable();
-
- // Use LinkedHashMap to guarantee insertion order for the assertion below.
- Map map = new LinkedHashMap<>();
- map.put("A.zero", 0.0);
- map.put("B.positive", 3.0);
- writer.writeRuntimeMethods(map, 1000L, 1.0, "out.csv");
- writer.close();
-
- List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8);
- assertEquals(2, lines.size(), "Expected header + 1 data row");
- assertTrue(lines.get(1).contains("B.positive"));
- }
-
- /**
- * A method entry with negative power must be suppressed for the same
- * reason as zero power — the power filter is {@code powerWatts > 0}.
- */
- @Test
- void writeRuntimeMethods_negativePower_rowSkipped() throws Exception {
- ResultWriter writer = new ResultWriter(tempDir.toString());
- writer.verifyWritable();
-
- Map map = new LinkedHashMap<>();
- map.put("A.neg", -1.0);
- map.put("B.pos", 2.0);
- writer.writeRuntimeMethods(map, 1000L, 1.0, "out.csv");
- writer.close();
-
- List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8);
- assertEquals(2, lines.size());
- assertFalse(lines.get(1).contains("A.neg"));
- }
-
- /**
- * The {@code energy_joules} column must equal {@code power_watts × interval_seconds}.
- * This test uses power = 4.0 W and interval = 2.5 s, so the expected energy is 10.0 J.
- * Values are read back from the written file and compared with a tolerance of 1e-9
- * to accommodate any floating-point rounding in the nine-decimal-place format.
- */
- @Test
- void writeRuntimeMethods_energyEqualsPoworTimesInterval() throws Exception {
- ResultWriter writer = new ResultWriter(tempDir.toString());
- writer.verifyWritable();
- writer.writeRuntimeMethods(Map.of("m.method", 4.0), 1000L, 2.5, "out.csv");
- writer.close();
-
- List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8);
- String dataLine = lines.get(1);
- String[] cols = dataLine.split(",");
- // CSV columns: timestamp, branch, power_watts, energy_joules, interval_seconds
- double power = Double.parseDouble(cols[2]);
- double energy = Double.parseDouble(cols[3]);
- double interval = Double.parseDouble(cols[4]);
- assertEquals(power * interval, energy, 1e-9);
- assertEquals(4.0, power, 1e-9);
- assertEquals(10.0, energy, 1e-9);
- }
-
- /**
- * Power and energy values must be formatted with exactly nine decimal places
- * (format string {@code "%.9f"}) to preserve sub-milliwatt precision for
- * fine-grained energy attribution.
- */
- @Test
- void writeRuntimeMethods_usesNineDecimalPrecision() throws Exception {
- ResultWriter writer = new ResultWriter(tempDir.toString());
- writer.verifyWritable();
- // 1/3 is an irrational decimal — ensures the formatter actually rounds at 9 places.
- writer.writeRuntimeMethods(Map.of("m.m", 1.0 / 3.0), 1000L, 1.0, "out.csv");
- writer.close();
-
- List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8);
- String dataLine = lines.get(1);
- String[] cols = dataLine.split(",");
- // power_watts is column index 2
- assertTrue(cols[2].matches("-?\\d+\\.\\d{9}"), "Expected 9 decimal places, got: " + cols[2]);
- }
-
- /**
- * When {@code writeRuntimeMethods} is called multiple times on the same
- * file name, subsequent calls must append rows without writing the header
- * a second time. The header must appear exactly once at the top of the file.
- */
- @Test
- void writeRuntimeMethods_appendsWithoutDuplicateHeader() throws Exception {
- ResultWriter writer = new ResultWriter(tempDir.toString());
- writer.verifyWritable();
- writer.writeRuntimeMethods(Map.of("A.m", 1.0), 1000L, 1.0, "out.csv");
- writer.writeRuntimeMethods(Map.of("B.m", 2.0), 2000L, 1.0, "out.csv");
- writer.close();
-
- List lines = Files.readAllLines(tempDir.resolve("out.csv"), StandardCharsets.UTF_8);
- long headerCount = lines.stream()
- .filter(l -> l.startsWith("timestamp,branch"))
- .count();
- assertEquals(1, headerCount, "Header should appear exactly once");
- assertEquals(3, lines.size(), "Expected header + 2 data rows");
- }
-
- /**
- * If the output file already exists but is empty (zero bytes), a fresh
- * header must be prepended before the first data row. This covers the
- * edge case where the file was created externally or left empty by a
- * previous failed run.
- */
- @Test
- void writeRuntimeMethods_addsHeaderToEmptyPreexistingFile() throws Exception {
- // Pre-create an empty file to exercise the "size == 0" branch in the writer.
- Path outFile = tempDir.resolve("out.csv");
- Files.write(outFile, new byte[0]);
-
- ResultWriter writer = new ResultWriter(tempDir.toString());
- writer.verifyWritable();
- writer.writeRuntimeMethods(Map.of("X.m", 5.0), 1000L, 1.0, "out.csv");
- writer.close();
-
- List lines = Files.readAllLines(outFile, StandardCharsets.UTF_8);
- assertEquals("timestamp,branch,power_watts,energy_joules,interval_seconds", lines.get(0));
- }
-
- /**
- * Method (branch) names used as CSV field values are passed through
- * {@code csvEscape}. A branch name that contains a comma must be wrapped
- * in double-quotes so it reads back as a single field, not two separate
- * columns.
- */
- @Test
- void writeRuntimeMethods_csvEscapeAppliedToKey() throws Exception {
- ResultWriter writer = new ResultWriter(tempDir.toString());
- writer.verifyWritable();
- writer.writeRuntimeMethods(Map.of("foo,bar", 3.0), 1000L, 1.0, "out.csv");
- writer.close();
-
- String content = Files.readString(tempDir.resolve("out.csv"), StandardCharsets.UTF_8);
- assertTrue(content.contains("\"foo,bar\""), "Comma in key should be CSV-escaped");
- }
-
- /**
- * Calling {@code close()} more than once must not throw. The internal
- * writer map is cleared on the first call, so the second call must
- * silently do nothing.
- */
- @Test
- void close_isIdempotent() throws Exception {
- ResultWriter writer = new ResultWriter(tempDir.toString());
- writer.verifyWritable();
- writer.writeRuntimeMethods(Map.of("A.m", 1.0), 1000L, 1.0, "out.csv");
- assertDoesNotThrow(() -> {
- writer.close();
- writer.close();
- });
- }
-}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSourceTest.java
deleted file mode 100644
index 76f6586..0000000
--- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreCSVSourceTest.java
+++ /dev/null
@@ -1,320 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.source;
-
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.attribute.FileTime;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-/**
- * Unit tests for {@link JoularCoreCSVSource}.
- *
- * The CSV source reads power data written by Joular Core into a rolling CSV file.
- * Tests are grouped into three areas:
- *
- * - {@code parseCpuPower} — parsing of individual CSV rows, including rejection of
- * non-finite and negative values.
- * - {@code isHeaderLine} — structural detection of header/comment rows based on
- * whether the first column is numeric.
- * - {@code getCurrentPower} — end-to-end file reading, caching by size+mtime, and
- * the last-known-power fallback on parse errors.
- *
- *
- * File-based tests use JUnit 5 {@code @TempDir} for full isolation.
- */
-class JoularCoreCSVSourceTest {
-
- @TempDir
- Path tempDir;
-
- // -------------------------------------------------------------------------
- // parseCpuPower
- // -------------------------------------------------------------------------
-
- /**
- * A well-formed CSV row with a positive numeric second column must return
- * that value as a {@code Double}. Column indices are zero-based, so the
- * cpu_power field lives at index 1.
- */
- @Test
- void parseCpuPower_validLine_returnsValue() {
- assertEquals(15.5, JoularCoreCSVSource.parseCpuPower("1000,15.5,extra"), 1e-9);
- }
-
- /**
- * Zero is a valid (non-negative, finite) power reading and must be
- * returned as-is. The guard is {@code value < 0}, so exactly zero passes.
- */
- @Test
- void parseCpuPower_zeroValue_returnsZero() {
- assertEquals(0.0, JoularCoreCSVSource.parseCpuPower("1000,0.0,x"), 1e-9);
- }
-
- /**
- * A negative power value is physically impossible for CPU power and must
- * be rejected by returning {@code null}.
- */
- @Test
- void parseCpuPower_negativeValue_returnsNull() {
- assertNull(JoularCoreCSVSource.parseCpuPower("1000,-5.0,x"));
- }
-
- /**
- * {@code Double.parseDouble("Infinity")} succeeds in Java, so the
- * {@code !Double.isFinite(value)} guard is necessary to reject it.
- */
- @Test
- void parseCpuPower_infinityValue_returnsNull() {
- assertNull(JoularCoreCSVSource.parseCpuPower("1000,Infinity,x"));
- }
-
- /**
- * {@code Double.parseDouble("NaN")} succeeds in Java, so the
- * {@code !Double.isFinite(value)} guard must also reject NaN.
- */
- @Test
- void parseCpuPower_nanValue_returnsNull() {
- assertNull(JoularCoreCSVSource.parseCpuPower("1000,NaN,x"));
- }
-
- /**
- * A non-numeric second column (e.g., a label or corrupted data) must
- * cause a {@code NumberFormatException} that is caught and converted to
- * a {@code null} return value.
- */
- @Test
- void parseCpuPower_nonNumericValue_returnsNull() {
- assertNull(JoularCoreCSVSource.parseCpuPower("1000,abc,x"));
- }
-
- /**
- * When the second column is present but empty, the parser must return
- * {@code null} rather than throw. An empty field after trim means there
- * is nothing to parse.
- */
- @Test
- void parseCpuPower_emptySecondColumn_returnsNull() {
- assertNull(JoularCoreCSVSource.parseCpuPower("1000,,x"));
- }
-
- /**
- * A line with only one column (no comma) means {@code parts.length <= 1}
- * and there is no second field to parse — must return {@code null}.
- */
- @Test
- void parseCpuPower_singleColumn_returnsNull() {
- assertNull(JoularCoreCSVSource.parseCpuPower("1000"));
- }
-
- /**
- * Whitespace around the numeric value must be stripped by {@code trim()}
- * before parsing so that formatting differences in Joular Core output do
- * not cause spurious failures.
- */
- @Test
- void parseCpuPower_whitespaceAroundValue_trimmedCorrectly() {
- assertEquals(7.77, JoularCoreCSVSource.parseCpuPower("1000, 7.77 ,x"), 1e-9);
- }
-
- // -------------------------------------------------------------------------
- // isHeaderLine
- // -------------------------------------------------------------------------
-
- /**
- * A data row whose first column is an integer timestamp must not be
- * classified as a header; {@code Double.parseDouble} succeeds → {@code false}.
- */
- @Test
- void isHeaderLine_numericFirstColumn_false() {
- assertFalse(JoularCoreCSVSource.isHeaderLine("1234567890,5.0"));
- }
-
- /**
- * A row whose first column is a word (e.g., "timestamp") is the
- * column-name header row; {@code Double.parseDouble} throws → {@code true}.
- */
- @Test
- void isHeaderLine_textFirstColumn_true() {
- assertTrue(JoularCoreCSVSource.isHeaderLine("timestamp,cpu_power"));
- }
-
- /**
- * A row that starts with a comma has an empty first column. An empty
- * string fails {@code Double.parseDouble}, so it is treated as a header.
- */
- @Test
- void isHeaderLine_emptyFirstColumn_true() {
- assertTrue(JoularCoreCSVSource.isHeaderLine(",5.0"));
- }
-
- /**
- * A floating-point timestamp (e.g., from a higher-resolution clock) is
- * still a valid numeric first column and must not be misidentified as a
- * header.
- */
- @Test
- void isHeaderLine_floatFirstColumn_false() {
- assertFalse(JoularCoreCSVSource.isHeaderLine("1234.56,5.0"));
- }
-
- /**
- * A line with no comma at all is treated as a single-column line.
- * If that single value is numeric, the line is a data row, not a header.
- */
- @Test
- void isHeaderLine_noCommaNumericWholeLine_false() {
- assertFalse(JoularCoreCSVSource.isHeaderLine("9876"));
- }
-
- /**
- * A single-column non-numeric line (e.g., a standalone label) must be
- * classified as a header.
- */
- @Test
- void isHeaderLine_noCommaTextWholeLine_true() {
- assertTrue(JoularCoreCSVSource.isHeaderLine("header"));
- }
-
- // -------------------------------------------------------------------------
- // getCurrentPower
- // -------------------------------------------------------------------------
-
- /**
- * When the configured CSV file does not exist, the source must return 0.0
- * (the initial {@code lastKnownPower}) rather than throwing or returning a
- * negative sentinel. It must also log a warning rather than propagating an
- * exception.
- */
- @Test
- void getCurrentPower_missingFile_returnsZero() {
- JoularCoreCSVSource source = new JoularCoreCSVSource(
- tempDir.resolve("nonexistent.csv").toString());
- Logger logger = Logger.getLogger(JoularCoreCSVSource.class.getName());
- Level oldLevel = logger.getLevel();
- logger.setLevel(Level.OFF);
- try {
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
- } finally {
- logger.setLevel(oldLevel);
- }
- }
-
- /**
- * A file containing a valid header row followed by a data row must
- * return the cpu_power value from that data row.
- */
- @Test
- void getCurrentPower_validFile_returnsParsedPower() throws Exception {
- Path csv = tempDir.resolve("power.csv");
- Files.writeString(csv, "timestamp,cpu_power\n1000,25.5\n", StandardCharsets.UTF_8);
-
- JoularCoreCSVSource source = new JoularCoreCSVSource(csv.toString());
- assertEquals(25.5, source.getCurrentPower(), 1e-9);
- }
-
- /**
- * When the file's size and last-modified time are identical across two
- * consecutive calls, the source must skip the file read and return the
- * cached value from the first call. This avoids unnecessary I/O on every
- * monitoring tick when Joular Core has not written new data.
- *
- *
The test verifies this by calling the same source twice on an
- * unmodified file and asserting both calls return the same value.
- * Additionally it overwrites the file with new content but resets the
- * mtime to the original value to confirm the cache key is (size, mtime).
- */
- @Test
- void getCurrentPower_fileUnchanged_returnsCachedValue() throws Exception {
- Path csv = tempDir.resolve("power.csv");
- Files.writeString(csv, "timestamp,cpu_power\n1000,10.0\n", StandardCharsets.UTF_8);
-
- JoularCoreCSVSource source = new JoularCoreCSVSource(csv.toString());
- double first = source.getCurrentPower();
- assertEquals(10.0, first, 1e-9);
-
- // Overwrite the file with different content but restore the original mtime
- // so the cache key (size, mtime) appears unchanged.
- FileTime originalTime = Files.getLastModifiedTime(csv);
- Files.writeString(csv, "timestamp,cpu_power\n1000,99.9\n", StandardCharsets.UTF_8);
- Files.setLastModifiedTime(csv, originalTime);
-
- // A fresh source on the already-modified file reads correctly.
- JoularCoreCSVSource source2 = new JoularCoreCSVSource(csv.toString());
- double val = source2.getCurrentPower();
- // Calling again on a truly unchanged file must return the cached value.
- double val2 = source2.getCurrentPower();
- assertEquals(val, val2, 1e-9, "Repeated call on unchanged file should return same value");
- }
-
- /**
- * A file that contains only a header row (no data rows) must leave
- * {@code lastKnownPower} at its initial value of 0.0, because there is
- * nothing to parse.
- */
- @Test
- void getCurrentPower_headerOnlyFile_returnsLastKnown() throws Exception {
- Path csv = tempDir.resolve("power.csv");
- Files.writeString(csv, "timestamp,cpu_power\n", StandardCharsets.UTF_8);
-
- JoularCoreCSVSource source = new JoularCoreCSVSource(csv.toString());
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
- }
-
- /**
- * When the file contains multiple data rows, the source must return
- * the value from the last non-empty, non-header row — which represents
- * the most recent power measurement written by Joular Core.
- */
- @Test
- void getCurrentPower_fileWithMultipleRows_returnsLastDataRow() throws Exception {
- Path csv = tempDir.resolve("power.csv");
- Files.writeString(csv,
- "timestamp,cpu_power\n1000,5.0\n2000,12.0\n3000,18.0\n",
- StandardCharsets.UTF_8);
-
- JoularCoreCSVSource source = new JoularCoreCSVSource(csv.toString());
- assertEquals(18.0, source.getCurrentPower(), 1e-9);
- }
-
- /**
- * When a new row cannot be parsed (e.g., a corrupted or partially written
- * line), the source must return the previously cached valid power value
- * rather than 0.0 or throwing. This is the "last-known-power" smoothing
- * contract shared by all {@link PowerSource} implementations.
- *
- *
The test primes the cache by reading a valid file, then waits briefly
- * to guarantee the OS updates the mtime, writes an invalid row, and asserts
- * the original value is returned.
- */
- @Test
- void getCurrentPower_parseFailure_returnsLastKnownPower() throws Exception {
- Path csv = tempDir.resolve("power.csv");
- // First read: prime lastKnownPower with 7.0.
- Files.writeString(csv, "timestamp,cpu_power\n1000,7.0\n", StandardCharsets.UTF_8);
- JoularCoreCSVSource source = new JoularCoreCSVSource(csv.toString());
- assertEquals(7.0, source.getCurrentPower(), 1e-9);
-
- // Sleep briefly so the OS assigns a different mtime to the second write,
- // ensuring the cache is invalidated and the file is actually re-read.
- Thread.sleep(10);
- Files.writeString(csv, "timestamp,cpu_power\n2000,not-a-number\n", StandardCharsets.UTF_8);
- assertEquals(7.0, source.getCurrentPower(), 1e-9, "Should return last known power on parse failure");
- }
-}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSourceTest.java
deleted file mode 100644
index 02cc7b7..0000000
--- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreHttpSourceTest.java
+++ /dev/null
@@ -1,321 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.source;
-
-import com.sun.net.httpserver.HttpServer;
-import org.junit.jupiter.api.Test;
-
-import java.net.InetSocketAddress;
-import java.nio.charset.StandardCharsets;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-/**
- * Unit tests for {@link JoularCoreHttpSource}.
- *
- *
No live HTTP server is needed: the tests cover two independently testable
- * areas without making network calls:
- *
- * - Constructor validation — the constructor parses and validates the
- * configured URL at construction time so that misconfiguration is caught at
- * agent startup rather than silently at runtime.
- * - Static JSON helper — {@code extractCpuPower} uses Jackson Core's
- * streaming parser, so tests cover valid payloads and rejection of malformed
- * or structurally unsupported payloads.
- * - HTTP fallback behavior — a local JDK HTTP server verifies status
- * and body-size handling without an external service.
- *
- */
-class JoularCoreHttpSourceTest {
-
- // -------------------------------------------------------------------------
- // Constructor validation
- // -------------------------------------------------------------------------
-
- /**
- * A {@code null} URL must be rejected immediately at construction time
- * with an {@link IllegalArgumentException} rather than producing a
- * NullPointerException later during the first HTTP request.
- */
- @Test
- void constructor_nullUrl_throwsIllegalArgumentException() {
- assertThrows(IllegalArgumentException.class, () -> new JoularCoreHttpSource(null));
- }
-
- /**
- * A whitespace-only URL (after trim it becomes empty) must be rejected
- * the same way as a {@code null} URL.
- */
- @Test
- void constructor_emptyUrl_throwsIllegalArgumentException() {
- assertThrows(IllegalArgumentException.class, () -> new JoularCoreHttpSource(" "));
- }
-
- /**
- * Only {@code http} and {@code https} schemes are supported. An
- * {@code ftp://} URL must be rejected and the exception message must
- * mention the accepted schemes so the misconfiguration is easy to diagnose.
- */
- @Test
- void constructor_ftpScheme_throwsIllegalArgumentException() {
- IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
- () -> new JoularCoreHttpSource("ftp://example.com/data"));
- assertTrue(ex.getMessage().contains("http") || ex.getMessage().contains("https"),
- "Message should mention http/https");
- }
-
- /**
- * A URL that is syntactically valid as a URI but has an empty host must
- * be rejected. The input {@code "http://:8080"} parses successfully
- * (scheme=http, authority=":8080") yet {@code getHost()} returns an empty
- * string, triggering the missing-host guard.
- */
- @Test
- void constructor_missingHost_throwsIllegalArgumentException() {
- // "http://:8080" parses as a valid URI (scheme=http, authority=":8080"),
- // but getHost() returns "" → triggers the "missing host" guard.
- assertThrows(IllegalArgumentException.class,
- () -> new JoularCoreHttpSource("http://:8080"));
- }
-
- /**
- * A well-formed {@code http://} URL must be accepted without throwing.
- * The source is closed immediately to release the internal executor thread.
- */
- @Test
- void constructor_validHttpUrl_doesNotThrow() {
- assertDoesNotThrow(() -> {
- JoularCoreHttpSource source = new JoularCoreHttpSource("http://localhost:8080/data");
- source.close();
- });
- }
-
- /**
- * A well-formed {@code https://} URL must also be accepted, confirming
- * that TLS endpoints are supported in addition to plain HTTP.
- */
- @Test
- void constructor_validHttpsUrl_doesNotThrow() {
- assertDoesNotThrow(() -> {
- JoularCoreHttpSource source = new JoularCoreHttpSource("https://example.com/power");
- source.close();
- });
- }
-
- // -------------------------------------------------------------------------
- // extractCpuPower
- // -------------------------------------------------------------------------
-
- /**
- * The minimal valid JSON payload — a single-key object — must yield the
- * numeric value associated with {@code "cpu_power"}.
- */
- @Test
- void extractCpuPower_validJson_returnsValue() {
- assertEquals(42.5, JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":42.5}"), 1e-9);
- }
-
- /**
- * Extra whitespace around the colon and the value is allowed by JSON and
- * must be handled by the parser without returning {@code null}.
- */
- @Test
- void extractCpuPower_withWhitespace_returnsValue() {
- assertEquals(10.0, JoularCoreHttpSource.extractCpuPower("{ \"cpu_power\" : 10.0 }"), 1e-9);
- }
-
- /**
- * A negative power reading is physically invalid and must be rejected
- * by returning {@code null}. The guard is {@code value < 0}.
- */
- @Test
- void extractCpuPower_negativeValue_returnsNull() {
- assertNull(JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":-1.0}"));
- }
-
- /**
- * Zero watts is a valid (non-negative, finite) power value and must be
- * returned. The guard is strictly {@code < 0}, so exactly zero passes.
- */
- @Test
- void extractCpuPower_zeroValue_returnsZero() {
- assertEquals(0.0, JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":0.0}"), 1e-9);
- }
-
- /**
- * When the {@code "cpu_power"} key is absent, the parser must return
- * {@code null}.
- */
- @Test
- void extractCpuPower_missingKey_returnsNull() {
- assertNull(JoularCoreHttpSource.extractCpuPower("{\"other\":1}"));
- }
-
- /**
- * Reinforces the substring-guard requirement at the higher level: if the
- * JSON only contains {@code "last_cpu_power"} and not a top-level
- * {@code "cpu_power"}, the method must return {@code null}.
- */
- @Test
- void extractCpuPower_substringKeyOnly_returnsNull() {
- assertNull(JoularCoreHttpSource.extractCpuPower("{\"last_cpu_power\":5.0}"));
- }
-
- /**
- * A nested {@code cpu_power} field is not part of the supported Joular Core
- * HTTP contract. Only top-level numeric fields are accepted.
- */
- @Test
- void extractCpuPower_nestedCpuPower_returnsNull() {
- assertNull(JoularCoreHttpSource.extractCpuPower("{\"outer\":{\"cpu_power\":5.0}}"));
- }
-
- /**
- * Malformed JSON must be treated the same as any transient HTTP payload
- * problem: no value is parsed, so the caller can keep using last-known power.
- */
- @Test
- void extractCpuPower_malformedJson_returnsNull() {
- assertNull(JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":"));
- }
-
- /**
- * If a payload contains duplicate {@code cpu_power} fields, the first valid
- * top-level value is used. Joular Core should not produce duplicates, but this
- * deterministic behavior avoids order-dependent surprises.
- */
- @Test
- void extractCpuPower_duplicateField_returnsFirstValue() {
- assertEquals(4.0, JoularCoreHttpSource.extractCpuPower(
- "{\"cpu_power\":4.0,\"cpu_power\":9.0}"), 1e-9);
- }
-
- /**
- * Scientific notation (lowercase {@code e}) is a standard JSON number
- * format and must be parsed correctly by {@code Double.parseDouble}.
- * {@code 1.5e1} equals {@code 15.0}.
- */
- @Test
- void extractCpuPower_scientificNotation_parsed() {
- assertEquals(15.0, JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":1.5e1}"), 1e-9);
- }
-
- /**
- * Scientific notation with an uppercase {@code E} must also be accepted.
- * {@code 2E2} equals {@code 200.0}.
- */
- @Test
- void extractCpuPower_scientificNotationUppercase_parsed() {
- assertEquals(200.0, JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":2E2}"), 1e-9);
- }
-
- /**
- * When the value associated with {@code "cpu_power"} is a quoted string
- * rather than a number, the first character after the colon is {@code "}
- * which does not match any character in the numeric scanning loop. The
- * loop terminates immediately with {@code start == i}, and the method
- * must return {@code null}.
- */
- @Test
- void extractCpuPower_nonNumericValue_returnsNull() {
- // The character after ':' is '"' — not a digit, sign, dot, or exponent marker.
- assertNull(JoularCoreHttpSource.extractCpuPower("{\"cpu_power\":\"text\"}"));
- }
-
- /**
- * When the JSON object contains multiple power fields, only the value
- * associated with the structurally correct {@code "cpu_power"} key must
- * be returned; other fields must be ignored.
- */
- @Test
- void extractCpuPower_multipleKeys_correctKeyReturned() {
- assertEquals(7.0, JoularCoreHttpSource.extractCpuPower(
- "{\"gpu_power\":3.0,\"cpu_power\":7.0,\"total\":10.0}"), 1e-9);
- }
-
- // -------------------------------------------------------------------------
- // HTTP fallback behavior
- // -------------------------------------------------------------------------
-
- @Test
- void getCurrentPower_non200Status_returnsLastKnownPower() throws Exception {
- HttpServer server = startServer(503, "{\"cpu_power\":9.0}");
- try {
- JoularCoreHttpSource source = new JoularCoreHttpSource(serverUrl(server));
- try {
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
- } finally {
- source.close();
- }
- } finally {
- server.stop(0);
- }
- }
-
- @Test
- void getCurrentPower_oversizedBody_returnsLastKnownPower() throws Exception {
- String oversized = "{\"cpu_power\":1.0,\"padding\":\"" + "x".repeat(65 * 1024) + "\"}";
- HttpServer server = startServer(200, oversized);
- try {
- JoularCoreHttpSource source = new JoularCoreHttpSource(serverUrl(server));
- try {
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
- } finally {
- source.close();
- }
- } finally {
- server.stop(0);
- }
- }
-
- @Test
- void getCurrentPower_validThenMalformed_returnsLastKnownPower() throws Exception {
- HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
- final int[] calls = {0};
- server.createContext("/data", exchange -> {
- calls[0]++;
- byte[] response = (calls[0] == 1 ? "{\"cpu_power\":12.5}" : "{\"cpu_power\":")
- .getBytes(StandardCharsets.UTF_8);
- exchange.sendResponseHeaders(200, response.length);
- exchange.getResponseBody().write(response);
- exchange.close();
- });
- server.start();
- try {
- JoularCoreHttpSource source = new JoularCoreHttpSource(serverUrl(server));
- try {
- assertEquals(12.5, source.getCurrentPower(), 1e-9);
- assertEquals(12.5, source.getCurrentPower(), 1e-9);
- } finally {
- source.close();
- }
- } finally {
- server.stop(0);
- }
- }
-
- private static HttpServer startServer(int status, String body) throws Exception {
- HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
- server.createContext("/data", exchange -> {
- byte[] response = body.getBytes(StandardCharsets.UTF_8);
- exchange.sendResponseHeaders(status, response.length);
- exchange.getResponseBody().write(response);
- exchange.close();
- });
- server.start();
- return server;
- }
-
- private static String serverUrl(HttpServer server) {
- return "http://127.0.0.1:" + server.getAddress().getPort() + "/data";
- }
-}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSourceTest.java
deleted file mode 100644
index 165746f..0000000
--- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/JoularCoreRingBufferSourceTest.java
+++ /dev/null
@@ -1,262 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.source;
-
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.condition.EnabledOnOs;
-import org.junit.jupiter.api.condition.OS;
-import org.junit.jupiter.api.io.CleanupMode;
-import org.junit.jupiter.api.io.TempDir;
-
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-/**
- * Unit tests for {@link JoularCoreRingBufferSource}.
- *
- * The ring buffer is a fixed-size shared-memory region written by Joular Core and
- * read by this source via a memory-mapped file. The binary layout is:
- *
- * Offset 0 : u64 head (index of the most-recently written entry + 1)
- * Offset 8 : entry[0] (48 bytes)
- * Offset 56 : entry[1]
- * ...
- * Each entry:
- * +0 u64 timestamp
- * +8 f64 cpu_power ← the field we read
- * +16 f64 gpu_power
- * +24 f64 total_power
- * +32 f64 cpu_usage
- * +40 f64 pid_app_power
- *
- * Total file size: {@code 8 + 5 × 48 = 248} bytes.
- *
- * Platform handling
- * The temp directory is not deleted automatically because Windows can keep a
- * mapped file locked until the JVM releases the {@code MappedByteBuffer}. This
- * lets the file-mapping tests run on Windows instead of being reported as skipped.
- */
-class JoularCoreRingBufferSourceTest {
-
- /** Entry size in bytes: one u64 timestamp + five f64 values. */
- private static final int ENTRY_SIZE = 48;
-
- /** Number of entries in the ring buffer. */
- private static final int BUFFER_SIZE = 5;
-
- /** Total file size: 8-byte head counter followed by BUFFER_SIZE entries. */
- private static final int FILE_SIZE = 8 + BUFFER_SIZE * ENTRY_SIZE; // 248
-
- @TempDir(cleanup = CleanupMode.NEVER)
- Path tempDir;
-
- /**
- * Creates a 248-byte temp file whose binary layout matches what Joular Core
- * would write. The {@code head} value is placed at offset 0; the
- * {@code cpuPower} value is placed at the correct offset within entry
- * {@code entryIdx}.
- *
- *
The buffer uses {@link ByteOrder#nativeOrder()} to match the production
- * code, which calls {@code buffer.order(ByteOrder.nativeOrder())} after
- * mapping the file.
- *
- * @param head the head counter to write at offset 0
- * @param entryIdx the zero-based slot index (0–4) to store the cpu_power value
- * @param cpuPower the cpu_power value to place in the entry
- * @return path of the written temp file
- */
- private Path writeTempRingBuffer(long head, int entryIdx, double cpuPower) throws Exception {
- ByteBuffer buf = ByteBuffer.allocate(FILE_SIZE).order(ByteOrder.nativeOrder());
- buf.putLong(0, head);
- // Each entry starts at offset 8 + entryIdx * ENTRY_SIZE;
- // cpu_power is the second field (after the 8-byte timestamp), so +8.
- int cpuPowerOffset = 8 + entryIdx * ENTRY_SIZE + 8;
- buf.putDouble(cpuPowerOffset, cpuPower);
- Path file = tempDir.resolve("ring.bin");
- Files.write(file, buf.array());
- return file;
- }
-
- // -------------------------------------------------------------------------
- // Platform-independent tests
- // -------------------------------------------------------------------------
-
- /**
- * When {@code initialize()} has never been called, the internal
- * {@code ByteBuffer} is {@code null} and {@code getCurrentPower()} must
- * return 0.0 immediately without throwing.
- */
- @Test
- void getCurrentPower_bufferNull_returnsZero() {
- JoularCoreRingBufferSource source = new JoularCoreRingBufferSource("/nonexistent");
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
- }
-
- /**
- * After {@code close()} is called, the internal buffer is set to
- * {@code null}. A subsequent call to {@code getCurrentPower()} must
- * therefore return 0.0 (the null-buffer fast-path) and not throw.
- */
- @Test
- void close_setsBufferNull_subsequentGetReturnsZero() throws Exception {
- Path file = writeTempRingBuffer(1L, 0, 20.0);
- JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString());
- source.initialize();
- // Confirm a successful read before closing.
- source.getCurrentPower();
- source.close();
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
- }
-
- // -------------------------------------------------------------------------
- // File-mapping tests
- // -------------------------------------------------------------------------
-
- /**
- * With {@code head = 1}, the ring index is {@code (1 - 1) % 5 = 0}, so
- * the cpu_power field lives at byte offset {@code 8 + 0 × 48 + 8 = 16}.
- * This is the simplest valid reading and validates the base-case slot
- * calculation.
- */
- @Test
- void getCurrentPower_validHead1_returnsCpuPower() throws Exception {
- // head=1 → idx=(1-1)%5=0 → entry at offset 8, cpu_power at offset 16
- Path file = writeTempRingBuffer(1L, 0, 25.0);
- JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString());
- source.initialize();
- assertEquals(25.0, source.getCurrentPower(), 1e-9);
- }
-
- /**
- * With {@code head = 3}, the ring index is {@code (3 - 1) % 5 = 2}, so
- * the cpu_power field lives at byte offset {@code 8 + 2 × 48 + 8 = 112}.
- * This verifies the modulo ring-slot arithmetic for non-zero indices.
- */
- @Test
- void getCurrentPower_validHead3_returnsCorrectSlot() throws Exception {
- // head=3 → idx=(3-1)%5=2 → entry at offset 8+2*48=104, cpu_power at 112
- Path file = writeTempRingBuffer(3L, 2, 18.0);
- JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString());
- source.initialize();
- assertEquals(18.0, source.getCurrentPower(), 1e-9);
- }
-
- /**
- * A head value of 0 means Joular Core has not written any entry yet.
- * The source must treat this as "no data available" and return
- * {@code lastKnownPower} (initially 0.0).
- */
- @Test
- void getCurrentPower_headZero_returnsZero() throws Exception {
- Path file = writeTempRingBuffer(0L, 0, 99.0);
- JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString());
- source.initialize();
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
- }
-
- /**
- * A negative head value is outside the valid range ({@code head <= 0} check)
- * and must be handled the same as zero — return 0.0 without throwing.
- */
- @Test
- void getCurrentPower_headNegative_returnsZero() throws Exception {
- Path file = writeTempRingBuffer(-1L, 0, 10.0);
- JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString());
- source.initialize();
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
- }
-
- /**
- * A negative cpu_power value in the ring buffer is physically impossible
- * and must be rejected. The source must return {@code lastKnownPower}
- * (0.0 on the first call) rather than propagating the bad reading.
- */
- @Test
- void getCurrentPower_negativeCpuPower_returnsLastKnown() throws Exception {
- Path file = writeTempRingBuffer(1L, 0, -5.0);
- JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString());
- source.initialize();
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
- }
-
- /**
- * {@code Double.longBitsToDouble} for a NaN bit pattern is finite=false,
- * so the {@code !Double.isFinite(value)} guard must reject it and return
- * {@code lastKnownPower} (0.0).
- */
- @Test
- void getCurrentPower_nanCpuPower_returnsLastKnown() throws Exception {
- Path file = writeTempRingBuffer(1L, 0, Double.NaN);
- JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString());
- source.initialize();
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
- }
-
- /**
- * Once a valid value has been read, it is cached as {@code lastKnownPower}.
- * Calling {@code getCurrentPower()} again on an unmodified buffer must
- * return the same value — both because the buffer hasn't changed and
- * because the load-verify check (head1 == head2) passes on a static file.
- */
- @Test
- void getCurrentPower_validThenInvalid_returnsLastKnownGoodValue() throws Exception {
- Path file = writeTempRingBuffer(1L, 0, 30.0);
- JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(file.toString());
- source.initialize();
- assertEquals(30.0, source.getCurrentPower(), 1e-9);
- // Second call on the same static buffer — head is unchanged so
- // no torn-read is detected and the cached value is returned.
- assertEquals(30.0, source.getCurrentPower(), 1e-9);
- }
-
- /**
- * When the mapped file does not exist, {@code initialize()} must propagate
- * the {@link java.io.FileNotFoundException} (or equivalent) as an
- * {@link Exception} so the agent can fail fast at startup.
- */
- @Test
- void initialize_missingFile_throwsException() {
- JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(
- tempDir.resolve("does-not-exist.bin").toString());
- assertThrows(Exception.class, source::initialize);
- }
-
- // -------------------------------------------------------------------------
- // Windows-only: named shared memory fallback
- // -------------------------------------------------------------------------
-
- /**
- * On Windows, when the configured path does not correspond to an existing
- * file, {@code initialize()} falls back to opening a named file-mapping
- * object via {@code Kernel32.OpenFileMappingW}. If no such mapping exists,
- * {@code OpenFileMappingW} returns a null or invalid handle, and the method
- * must throw an {@link Exception} rather than silently proceeding with a
- * null buffer.
- *
- *
This test confirms that the Windows code path (JNA call) is reachable
- * and behaves correctly when the mapping is absent, without requiring a
- * live Joular Core process.
- */
- @Test
- @EnabledOnOs(OS.WINDOWS)
- void initialize_onWindows_sharedMemoryPath_throwsWhenMappingAbsent() {
- // A name that exists neither as a file on disk nor as a Windows named
- // mapping — forces the fallback to mapWindowsSharedMemory(), which throws.
- JoularCoreRingBufferSource source = new JoularCoreRingBufferSource(
- "Local\\NonExistentJoularMapping_TestOnly_12345");
- assertThrows(Exception.class, source::initialize);
- }
-}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSourceTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSourceTest.java
deleted file mode 100644
index 0d281af..0000000
--- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/LinuxRaplPowerSourceTest.java
+++ /dev/null
@@ -1,261 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.source;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.time.Clock;
-import java.time.Duration;
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.ZoneOffset;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-
-class LinuxRaplPowerSourceTest {
-
- @TempDir
- Path tempDir;
-
- private final MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
-
- private Path writeRaplPackage(String name, String energyMicrojoules, String maxMicrojoules) throws Exception {
- Path pkg = tempDir.resolve("intel-rapl-0");
- Files.createDirectories(pkg);
- Files.writeString(pkg.resolve("name"), name + "\n", StandardCharsets.UTF_8);
- Files.writeString(pkg.resolve("energy_uj"), energyMicrojoules + "\n", StandardCharsets.UTF_8);
- Files.writeString(pkg.resolve("max_energy_range_uj"), maxMicrojoules + "\n", StandardCharsets.UTF_8);
- return pkg;
- }
-
- private void writeEnergy(Path pkg, String energyMicrojoules) throws Exception {
- Files.writeString(pkg.resolve("energy_uj"), energyMicrojoules + "\n", StandardCharsets.UTF_8);
- }
-
- private LinuxRaplPowerSource sourceFor(Path pkg) {
- return new LinuxRaplPowerSource(pkg, "Linux", clock);
- }
-
- @Test
- void getCurrentPower_validPackage_calculatesPowerFromEnergyDelta() throws Exception {
- Path pkg = writeRaplPackage("package-0", "1000000", "10000000");
- LinuxRaplPowerSource source = sourceFor(pkg);
- source.initialize();
-
- writeEnergy(pkg, "3000000");
- clock.advance(Duration.ofSeconds(2));
-
- assertEquals(1.0, source.getCurrentPower(), 1e-9);
- }
-
- @Test
- void getCurrentPower_seededInitialEnergyDoesNotBecomePower() throws Exception {
- Path pkg = writeRaplPackage("package-0", "1000000", "10000000");
- LinuxRaplPowerSource source = sourceFor(pkg);
- source.initialize();
-
- clock.advance(Duration.ofSeconds(1));
-
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
- }
-
- @Test
- void getCurrentPower_counterWrap_usesMaxEnergyRange() throws Exception {
- Path pkg = writeRaplPackage("package-0", "9000000", "10000000");
- LinuxRaplPowerSource source = sourceFor(pkg);
- source.initialize();
-
- writeEnergy(pkg, "1000000");
- clock.advance(Duration.ofSeconds(2));
-
- assertEquals(1.0, source.getCurrentPower(), 1e-9);
- }
-
- @Test
- void initialize_nonLinux_throwsException() throws Exception {
- Path pkg = writeRaplPackage("package-0", "1000000", "10000000");
- LinuxRaplPowerSource source = new LinuxRaplPowerSource(pkg, "Windows 11", clock);
-
- assertThrows(Exception.class, source::initialize);
- }
-
- @Test
- void initialize_wrongPackageDomain_throwsException() throws Exception {
- Path pkg = writeRaplPackage("core", "1000000", "10000000");
- LinuxRaplPowerSource source = sourceFor(pkg);
-
- assertThrows(Exception.class, source::initialize);
- }
-
- @Test
- void initialize_missingEnergyFile_throwsException() throws Exception {
- Path pkg = tempDir.resolve("intel-rapl-0");
- Files.createDirectories(pkg);
- Files.writeString(pkg.resolve("name"), "package-0\n", StandardCharsets.UTF_8);
- Files.writeString(pkg.resolve("max_energy_range_uj"), "10000000\n", StandardCharsets.UTF_8);
- LinuxRaplPowerSource source = sourceFor(pkg);
-
- assertThrows(Exception.class, source::initialize);
- }
-
- @Test
- void initialize_nonRegularMaxFile_throwsException() throws Exception {
- Path pkg = tempDir.resolve("intel-rapl-0");
- Files.createDirectories(pkg);
- Files.writeString(pkg.resolve("name"), "package-0\n", StandardCharsets.UTF_8);
- Files.writeString(pkg.resolve("energy_uj"), "1000000\n", StandardCharsets.UTF_8);
- Files.createDirectory(pkg.resolve("max_energy_range_uj"));
- LinuxRaplPowerSource source = sourceFor(pkg);
-
- assertThrows(Exception.class, source::initialize);
- }
-
- @Test
- void initialize_malformedMaxEnergyRange_throwsException() throws Exception {
- Path pkg = writeRaplPackage("package-0", "1000000", "not-a-number");
- LinuxRaplPowerSource source = sourceFor(pkg);
-
- assertThrows(Exception.class, source::initialize);
- }
-
- @Test
- void initialize_impossibleInitialEnergy_throwsException() throws Exception {
- Path pkg = writeRaplPackage("package-0", "11000000", "10000000");
- LinuxRaplPowerSource source = sourceFor(pkg);
-
- assertThrows(Exception.class, source::initialize);
- }
-
- @Test
- void getCurrentPower_parseFailure_returnsLastKnownPower() throws Exception {
- Path pkg = writeRaplPackage("package-0", "1000000", "10000000");
- LinuxRaplPowerSource source = sourceFor(pkg);
- source.initialize();
- writeEnergy(pkg, "3000000");
- clock.advance(Duration.ofSeconds(1));
- assertEquals(2.0, source.getCurrentPower(), 1e-9);
-
- writeEnergy(pkg, "not-a-number");
- clock.advance(Duration.ofSeconds(1));
-
- assertEquals(2.0, source.getCurrentPower(), 1e-9);
- }
-
- @Test
- void getCurrentPower_negativeEnergy_returnsLastKnownPower() throws Exception {
- Path pkg = writeRaplPackage("package-0", "1000000", "10000000");
- LinuxRaplPowerSource source = sourceFor(pkg);
- source.initialize();
- writeEnergy(pkg, "2000000");
- clock.advance(Duration.ofSeconds(1));
- assertEquals(1.0, source.getCurrentPower(), 1e-9);
-
- writeEnergy(pkg, "-1");
- clock.advance(Duration.ofSeconds(1));
-
- assertEquals(1.0, source.getCurrentPower(), 1e-9);
- }
-
- @Test
- void getCurrentPower_nonFiniteEnergy_returnsLastKnownPower() throws Exception {
- Path pkg = writeRaplPackage("package-0", "1000000", "10000000");
- LinuxRaplPowerSource source = sourceFor(pkg);
- source.initialize();
- writeEnergy(pkg, "2000000");
- clock.advance(Duration.ofSeconds(1));
- assertEquals(1.0, source.getCurrentPower(), 1e-9);
-
- writeEnergy(pkg, "NaN");
- clock.advance(Duration.ofSeconds(1));
-
- assertEquals(1.0, source.getCurrentPower(), 1e-9);
- }
-
- @Test
- void getCurrentPower_energyAboveMaxRange_returnsLastKnownPower() throws Exception {
- Path pkg = writeRaplPackage("package-0", "1000000", "10000000");
- LinuxRaplPowerSource source = sourceFor(pkg);
- source.initialize();
- writeEnergy(pkg, "2000000");
- clock.advance(Duration.ofSeconds(1));
- assertEquals(1.0, source.getCurrentPower(), 1e-9);
-
- writeEnergy(pkg, "11000000");
- clock.advance(Duration.ofSeconds(1));
-
- assertEquals(1.0, source.getCurrentPower(), 1e-9);
- }
-
- @Test
- void getCurrentPower_clockDoesNotAdvance_returnsLastKnownPowerWithoutUpdatingState() throws Exception {
- Path pkg = writeRaplPackage("package-0", "1000000", "10000000");
- LinuxRaplPowerSource source = sourceFor(pkg);
- source.initialize();
-
- writeEnergy(pkg, "2000000");
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
-
- clock.advance(Duration.ofSeconds(1));
- assertEquals(1.0, source.getCurrentPower(), 1e-9);
- }
-
- @Test
- void close_resetsSourceState() throws Exception {
- Path pkg = writeRaplPackage("package-0", "1000000", "10000000");
- LinuxRaplPowerSource source = sourceFor(pkg);
- source.initialize();
- writeEnergy(pkg, "2000000");
- clock.advance(Duration.ofSeconds(1));
- assertEquals(1.0, source.getCurrentPower(), 1e-9);
-
- source.close();
-
- assertEquals(0.0, source.getCurrentPower(), 1e-9);
- }
-
- private static final class MutableClock extends Clock {
- private Instant instant;
- private final ZoneId zone;
-
- private MutableClock(Instant instant) {
- this(instant, ZoneOffset.UTC);
- }
-
- private MutableClock(Instant instant, ZoneId zone) {
- this.instant = instant;
- this.zone = zone;
- }
-
- private void advance(Duration duration) {
- instant = instant.plus(duration);
- }
-
- @Override
- public ZoneId getZone() {
- return zone;
- }
-
- @Override
- public Clock withZone(ZoneId zone) {
- return new MutableClock(instant, zone);
- }
-
- @Override
- public Instant instant() {
- return instant;
- }
- }
-}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactoryTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactoryTest.java
deleted file mode 100644
index 0ff43b3..0000000
--- a/src/test/java/org/noureddine/joular/joularcodejava/agent/source/PowerSourceFactoryTest.java
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.source;
-
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-import org.noureddine.joular.joularcodejava.agent.utils.AgentProperties;
-
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-/**
- * Unit tests for {@link PowerSourceFactory}.
- *
- *
The factory reads {@code power-source-type} from {@link AgentProperties}
- * and constructs the corresponding {@link PowerSource} implementation. Each test
- * writes a minimal properties file to a {@code @TempDir} and points the system
- * property {@code joularcodejava.properties} at it so that {@link AgentProperties}
- * picks up the desired configuration.
- *
- *
System-property isolation
- * The system property is cleared in {@code @AfterEach} to prevent any test
- * from leaking configuration state into subsequent tests, regardless of execution
- * order.
- *
- *
AgentProperties default-fallback caveat
- * When a property value is empty or whitespace-only, {@link AgentProperties}
- * returns the built-in default rather than an empty string. Tests that exercise
- * error paths therefore use an explicitly invalid (non-empty) value rather than
- * trying to pass an empty one.
- */
-class PowerSourceFactoryTest {
-
- @TempDir
- Path tempDir;
-
- private Path propsFile;
-
- @BeforeEach
- void setUp() throws Exception {
- propsFile = tempDir.resolve("joularcodejava.properties");
- }
-
- @AfterEach
- void tearDown() {
- System.clearProperty("joularcodejava.properties");
- }
-
- /**
- * Writes {@code content} to the temp properties file and sets the system
- * property so that {@link AgentProperties} loads it.
- */
- private AgentProperties propsWithContent(String content) throws Exception {
- Files.writeString(propsFile, content, StandardCharsets.UTF_8);
- System.setProperty("joularcodejava.properties", propsFile.toString());
- return new AgentProperties();
- }
-
- // -------------------------------------------------------------------------
- // Successful dispatch
- // -------------------------------------------------------------------------
-
- /**
- * {@code power-source-type=csv} must produce a {@link JoularCoreCSVSource}
- * instance. The CSV path falls back to the default value; no further
- * configuration is needed.
- */
- @Test
- void getPowerSource_csv_returnsCSVSource() throws Exception {
- AgentProperties props = propsWithContent("power-source-type=csv\n");
- PowerSource source = PowerSourceFactory.getPowerSource(props);
- assertNotNull(source);
- assertInstanceOf(JoularCoreCSVSource.class, source);
- }
-
- /**
- * {@code power-source-type=http} with a valid HTTP URL must produce a
- * {@link JoularCoreHttpSource} instance. The source is closed after the
- * assertion to release the internal executor thread.
- */
- @Test
- void getPowerSource_http_returnsHttpSource() throws Exception {
- AgentProperties props = propsWithContent(
- "power-source-type=http\njoular-core-http-url=http://localhost:9999/data\n");
- PowerSource source = PowerSourceFactory.getPowerSource(props);
- assertNotNull(source);
- assertInstanceOf(JoularCoreHttpSource.class, source);
- source.close();
- }
-
- /**
- * {@code power-source-type=ringbuffer} must produce a
- * {@link JoularCoreRingBufferSource} instance. The path falls back to the
- * OS-appropriate default; no file needs to exist for construction.
- */
- @Test
- void getPowerSource_ringbuffer_returnsRingBufferSource() throws Exception {
- AgentProperties props = propsWithContent("power-source-type=ringbuffer\n");
- PowerSource source = PowerSourceFactory.getPowerSource(props);
- assertNotNull(source);
- assertInstanceOf(JoularCoreRingBufferSource.class, source);
- }
-
- /**
- * {@code power-source-type=rapl} must produce a {@link LinuxRaplPowerSource}
- * instance. RAPL availability is checked during {@code initialize()}, so this
- * construction test is platform-independent.
- */
- @Test
- void getPowerSource_rapl_returnsLinuxRaplSource() throws Exception {
- AgentProperties props = propsWithContent("power-source-type=rapl\n");
- PowerSource source = PowerSourceFactory.getPowerSource(props);
- assertNotNull(source);
- assertInstanceOf(LinuxRaplPowerSource.class, source);
- }
-
- /**
- * The type string is normalised to lowercase before the switch statement,
- * so {@code "CSV"} (all-caps) must produce the same result as {@code "csv"}.
- */
- @Test
- void getPowerSource_csvUppercase_returnsCSVSource() throws Exception {
- AgentProperties props = propsWithContent("power-source-type=CSV\n");
- PowerSource source = PowerSourceFactory.getPowerSource(props);
- assertNotNull(source);
- assertInstanceOf(JoularCoreCSVSource.class, source);
- }
-
- // -------------------------------------------------------------------------
- // Error paths
- // -------------------------------------------------------------------------
-
- /**
- * An unrecognised type string must result in {@code null} being returned so
- * the agent can detect the misconfiguration and abort startup cleanly, rather
- * than attempting to use an undefined source.
- */
- @Test
- void getPowerSource_unknownType_returnsNull() throws Exception {
- AgentProperties props = propsWithContent("power-source-type=grpc\n");
- assertNull(PowerSourceFactory.getPowerSource(props));
- }
-
- /**
- * When the HTTP URL has an invalid scheme (e.g., {@code ftp://}), the
- * {@link JoularCoreHttpSource} constructor throws an
- * {@link IllegalArgumentException}. The factory must catch it and return
- * {@code null} so that the agent can fail fast with a clear log message.
- */
- @Test
- void getPowerSource_invalidHttpUrl_returnsNull() throws Exception {
- AgentProperties props = propsWithContent(
- "power-source-type=http\njoular-core-http-url=ftp://bad-scheme\n");
- assertNull(PowerSourceFactory.getPowerSource(props));
- }
-
- /**
- * A URL without a scheme (e.g., {@code "localhost:8080"}) is parsed by
- * {@code java.net.URI} as having scheme {@code "localhost"}, which is not
- * {@code http} or {@code https}. The constructor throws
- * {@link IllegalArgumentException} and the factory must return {@code null}.
- *
- *
Note: {@link AgentProperties} replaces a genuinely empty property value
- * with its built-in default, so an empty {@code joular-core-http-url} would
- * not reach this path. This test uses a non-empty but scheme-less value
- * instead.
- */
- @Test
- void getPowerSource_noSchemeUrl_returnsNull() throws Exception {
- // "localhost:8080" parses as URI with scheme="localhost" (not http/https).
- AgentProperties props = propsWithContent(
- "power-source-type=http\njoular-core-http-url=localhost:8080\n");
- assertNull(PowerSourceFactory.getPowerSource(props));
- }
-}
diff --git a/src/test/java/org/noureddine/joular/joularcodejava/agent/utils/AgentPropertiesTest.java b/src/test/java/org/noureddine/joular/joularcodejava/agent/utils/AgentPropertiesTest.java
deleted file mode 100644
index 3bd1e2e..0000000
--- a/src/test/java/org/noureddine/joular/joularcodejava/agent/utils/AgentPropertiesTest.java
+++ /dev/null
@@ -1,305 +0,0 @@
-/*
- * Copyright (c) 2026, Adel Noureddine.
- * All rights reserved. This program and the accompanying materials
- * are made available under the terms of the
- * GNU Lesser General Public License v3.0 (LGPL-3.0-only)
- * which accompanies this distribution, and is available at
- * https://www.gnu.org/licenses/lgpl-3.0.en.html
- *
- * Author : Adel Noureddine
- */
-
-package org.noureddine.joular.joularcodejava.agent.utils;
-
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-/**
- * Unit tests for {@link AgentProperties}.
- *
- *
{@code AgentProperties} loads configuration in priority order:
- *
- * - The classpath resource {@code joularcodejava.properties} (all values
- * intentionally empty so the defaults are defined in code, not in the file).
- * - The file path pointed to by the system property
- * {@code joularcodejava.properties} (if set).
- * - A {@code joularcodejava.properties} file in the working directory
- * (only consulted if the system property is absent).
- *
- *
- * Test isolation
- * Every test sets the {@code joularcodejava.properties} system property before
- * constructing {@link AgentProperties} and clears it in {@code @AfterEach} so that
- * no test leaks configuration to its successors regardless of execution order.
- *
- *
Tests that want the built-in defaults point the system property at a path that
- * does not exist on disk; {@code AgentProperties} then falls back to the classpath
- * defaults without reading any external file.
- */
-class AgentPropertiesTest {
-
- @TempDir
- Path tempDir;
-
- @AfterEach
- void cleanup() {
- System.clearProperty("joularcodejava.properties");
- }
-
- /**
- * Constructs an {@link AgentProperties} backed only by the classpath defaults.
- * The system property is set to a non-existent path so neither the
- * system-property path nor the working-directory fallback can accidentally
- * load a local file during development.
- */
- private AgentProperties defaults() {
- System.setProperty("joularcodejava.properties",
- tempDir.resolve("nonexistent.properties").toString());
- return new AgentProperties();
- }
-
- /**
- * Writes {@code content} to a temp properties file, sets the system property
- * to point at it, and returns a freshly constructed {@link AgentProperties}.
- */
- private AgentProperties withContent(String content) throws Exception {
- Path f = tempDir.resolve("joularcodejava.properties");
- Files.writeString(f, content, StandardCharsets.UTF_8);
- System.setProperty("joularcodejava.properties", f.toString());
- return new AgentProperties();
- }
-
- // -------------------------------------------------------------------------
- // Default values (no external file)
- // -------------------------------------------------------------------------
-
- /**
- * When no external file is loaded, the power source type must default to
- * {@code "ringbuffer"} — the lowest-latency source, preferred over CSV or HTTP.
- */
- @Test
- void defaults_powerSourceType_ringbuffer() {
- assertEquals("ringbuffer", defaults().getPowerSourceType());
- }
-
- /**
- * The default CSV file path is {@code "joularcore-data.csv"}, resolved
- * relative to the agent's working directory.
- */
- @Test
- void defaults_csvPath_default() {
- assertEquals("joularcore-data.csv", defaults().getJoularCoreCsvPath());
- }
-
- /**
- * The default HTTP endpoint assumes a locally running Joular Core instance
- * on the standard port.
- */
- @Test
- void defaults_httpUrl_default() {
- assertEquals("http://localhost:8080/data", defaults().getJoularCoreHttpUrl());
- }
-
- /**
- * The default stack-sampling interval is 10 ms, a balance between
- * attribution accuracy and monitoring overhead.
- */
- @Test
- void defaults_sampleRate_ten() {
- assertEquals(10L, defaults().getSampleRateMs());
- }
-
- /**
- * The default results directory is {@code "joular-code-java-results"},
- * resolved relative to the agent's working directory.
- */
- @Test
- void defaults_resultsPath_default() {
- assertEquals("joular-code-java-results", defaults().getResultsPath());
- }
-
- /**
- * When no filtering prefix is configured, the returned list must be empty,
- * which means all observed branches appear in both output files.
- */
- @Test
- void defaults_filteringPrefixes_empty() {
- assertTrue(defaults().getMethodsFilteringPrefixes().isEmpty());
- }
-
- // -------------------------------------------------------------------------
- // Override from external file
- // -------------------------------------------------------------------------
-
- /**
- * A value supplied in the external file overrides the classpath default.
- * Setting {@code power-source-type=csv} must be reflected by the getter.
- */
- @Test
- void fromFile_powerSourceType_csv() throws Exception {
- assertEquals("csv", withContent("power-source-type=csv\n").getPowerSourceType());
- }
-
- /**
- * A property set to whitespace only is treated the same as absent: after
- * trimming, the value is empty, so the code falls back to the default.
- */
- @Test
- void fromFile_emptyValue_usesDefault() throws Exception {
- // Three spaces → trim → "" → use default "ringbuffer"
- assertEquals("ringbuffer", withContent("power-source-type= \n").getPowerSourceType());
- }
-
- /**
- * A valid positive integer for {@code stack-monitoring-sample-rate} must
- * be returned as a {@code long} without modification.
- */
- @Test
- void fromFile_sampleRate_50() throws Exception {
- assertEquals(50L, withContent("stack-monitoring-sample-rate=50\n").getSampleRateMs());
- }
-
- /**
- * Zero is not a valid sample rate (the constraint is {@code > 0}), so
- * the property must be rejected and the default 10 ms returned.
- */
- @Test
- void fromFile_zeroSampleRate_usesDefault() throws Exception {
- assertEquals(10L, withContent("stack-monitoring-sample-rate=0\n").getSampleRateMs());
- }
-
- /**
- * A negative value is also not a valid sample rate; the default must
- * be returned and a warning logged.
- */
- @Test
- void fromFile_negativeSampleRate_usesDefault() throws Exception {
- assertEquals(10L, withContent("stack-monitoring-sample-rate=-5\n").getSampleRateMs());
- }
-
- /**
- * A non-numeric value causes a {@code NumberFormatException} inside
- * {@code getPositiveLongProperty}, which logs a warning and returns the
- * default rather than propagating the exception.
- */
- @Test
- void fromFile_nonNumericSampleRate_usesDefault() throws Exception {
- assertEquals(10L, withContent("stack-monitoring-sample-rate=fast\n").getSampleRateMs());
- }
-
- /**
- * The CSV file path accepts any string value without validation, because
- * the file is opened lazily during the monitoring loop rather than at
- * configuration time.
- */
- @Test
- void fromFile_csvPath_customValue() throws Exception {
- assertEquals("/data/power.csv",
- withContent("joular-core-csv-path=/data/power.csv\n").getJoularCoreCsvPath());
- }
-
- /**
- * A fully qualified HTTP URL — including scheme, host, port, and path —
- * must be returned verbatim; no parsing or normalisation is applied
- * at the properties level.
- */
- @Test
- void fromFile_httpUrl_customValue() throws Exception {
- assertEquals("http://192.168.1.1:9090/power",
- withContent("joular-core-http-url=http://192.168.1.1:9090/power\n")
- .getJoularCoreHttpUrl());
- }
-
- /**
- * The results directory path accepts any string value and is returned
- * without modification.
- */
- @Test
- void fromFile_resultsPath_customValue() throws Exception {
- assertEquals("/tmp/results",
- withContent("results-path=/tmp/results\n").getResultsPath());
- }
-
- /**
- * A single prefix must be parsed into a one-element list with the value
- * trimmed.
- */
- @Test
- void fromFile_filteringPrefixes_single() throws Exception {
- List prefixes = withContent("methods-filtering-prefix=com.example\n")
- .getMethodsFilteringPrefixes();
- assertEquals(List.of("com.example"), prefixes);
- }
-
- /**
- * Multiple comma-separated prefixes must each be individually trimmed of
- * surrounding whitespace and returned as separate list entries.
- */
- @Test
- void fromFile_filteringPrefixes_multiple_trimmed() throws Exception {
- List prefixes = withContent(
- "methods-filtering-prefix=com.example, org.myapp , io.foo\n")
- .getMethodsFilteringPrefixes();
- assertEquals(List.of("com.example", "org.myapp", "io.foo"), prefixes);
- }
-
- /**
- * Empty tokens produced by consecutive commas (e.g., {@code "com.a,,com.b"})
- * must be silently discarded; only non-blank entries appear in the result.
- */
- @Test
- void fromFile_filteringPrefixes_emptyTokensSkipped() throws Exception {
- List prefixes = withContent(
- "methods-filtering-prefix=com.a,,com.b\n")
- .getMethodsFilteringPrefixes();
- assertEquals(List.of("com.a", "com.b"), prefixes);
- }
-
- // -------------------------------------------------------------------------
- // OS-dependent ring-buffer path
- // -------------------------------------------------------------------------
-
- /**
- * The default ring buffer path is determined at runtime by inspecting
- * {@code os.name}:
- *
- * - Windows → {@code "Local\JoularCoreRing"} (named file mapping)
- * - macOS → {@code "/tmp/joularcorering"}
- * - Linux → {@code "/dev/shm/joularcorering"} (shared-memory filesystem)
- *
- * The test branches on the current platform so it remains correct in all
- * three CI environments.
- */
- @Test
- void getRingBufferPath_default_isOsDependent() {
- String os = System.getProperty("os.name").toLowerCase();
- String path = defaults().getRingBufferPath();
- assertNotNull(path);
- assertFalse(path.isEmpty());
- if (os.contains("win")) {
- assertTrue(path.contains("JoularCoreRing"), "Windows path: " + path);
- } else if (os.contains("mac")) {
- assertEquals("/tmp/joularcorering", path);
- } else {
- assertEquals("/dev/shm/joularcorering", path);
- }
- }
-
- /**
- * An explicitly configured ring buffer path must override the OS-dependent
- * default, allowing the agent to point at a non-standard location.
- */
- @Test
- void getRingBufferPath_customValue_returnsCustom() throws Exception {
- assertEquals("/custom/ring",
- withContent("joular-core-ringbuffer-path=/custom/ring\n").getRingBufferPath());
- }
-}