diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e03c9a1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Keep all files to LF file endings +* text=auto eol=lf + +*.jar binary diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index b3cbb16..76968e3 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -16,31 +16,32 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + java: ['21', '25'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - - name: Set up JDK 21 - uses: actions/setup-java@v5 + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v6 with: - java-version: '21' + java-version: ${{ matrix.java }} distribution: 'temurin' cache: maven - name: Build and test with Maven - run: mvn -B verify --file pom.xml + run: mvn -B -ntp verify --file pom.xml - name: Upload test results if: always() uses: actions/upload-artifact@v7 with: - name: test-results-${{ matrix.os }} + name: test-results-${{ matrix.os }}-${{ matrix.java }} path: target/surefire-reports/ if-no-files-found: ignore - name: Upload JAR artifact - if: matrix.os == 'ubuntu-latest' + if: matrix.os == 'ubuntu-latest' && matrix.java == '21' uses: actions/upload-artifact@v7 with: name: joularcode-java diff --git a/.gitignore b/.gitignore index 7307a5a..033c45a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,19 +7,14 @@ release.properties dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties -# https://maven.apache.org/wrapper/#usage-without-binary-jar .mvn/wrapper/maven-wrapper.jar -# Eclipse m2e generated files -# Eclipse Core .project -# JDT-specific (Eclipse Java Development Tools) .classpath -. .DS_Store .idea/ .zed/ .vscode/ joular-code-java-results/ -joularcodejava.properties -*.class \ No newline at end of file +/joularcodejava.properties +*.class diff --git a/README.md b/README.md index ab08827..8f0eb65 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,45 @@ -# Joular Project Joular Code - Java +# Joular Project Joular Code for Java [![License: LGPL v3](https://img.shields.io/badge/License-LGPL%20v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0) [![Java](https://img.shields.io/badge/Java-21%2B-orange)](https://openjdk.java.net) -Joular Code - Java is a lightweight and efficient Java agent for monitoring the energy consumption of methods and execution branches at the source code level. +Joular Code for Java is a lightweight and efficient Java agent for monitoring the energy consumption of methods and execution branches at the source code level. This project is part of [Joular Code](https://github.com/joular/joularcode), and is the successor of [JoularJX](https://github.com/joular/joularjx). ## :rocket: Features - Monitor power consumption and energy of each method and execution branch at runtime -- Works as a Java agent — no source code instrumentation or modification needed +- Works as a Java agent, with no source code instrumentation or modification needed - Samples the JVM stack at high frequency (default: every 10 ms) and attributes energy every second -- Supports four power data source backends: - - Shared memory ring buffer (IPC) — lowest latency, recommended - - CSV file — file-based polling - - HTTP endpoint — remote or containerized setups - - Linux RAPL powercap PKG domain — direct hardware counter source +- No runtime dependencies: the agent runs on the JDK alone +- Measures CPU power in three ways: + - Linux RAPL, read directly from powercap: nothing else to install + - [PowerJoular](https://github.com/joular/powerjoular)'s shared memory ring buffer: Windows, macOS, Raspberry Pi, with the measuring in a separate process + - Inside a virtual machine, using a shared power file between the host and the guest - Generates CSV files with per-method and per-branch power (Watts) and energy (Joules) -- Produces two output sets: one for all methods (including JDK internals), one filtered to your application packages +- Produces two output sets: one for all methods (including JDK methods), and one filtered and calculated to your application's methods - Configurable method filtering by package/class prefix to focus energy data on your code - Cross-platform: Windows, macOS, Linux, and Raspberry Pi ## :bulb: How It Works -Joular Code - Java runs as a Java instrumentation agent alongside your application. Every monitoring cycle (default: 1 second), it: +Joular Code for Java runs as a Java agent alongside your application. Every monitoring cycle (about 1 second), it: -1. **Samples the JVM stack** — every `stack-monitoring-sample-rate` milliseconds, it captures the stack trace of every `RUNNABLE` thread, building sample counts for each call branch. -2. **Measures thread CPU time** — it takes CPU-time snapshots at the start and end of each cycle using `ThreadMXBean`, computing how much CPU time each thread consumed during the window. -3. **Reads system power** from Joular Core — the total CPU power in Watts for the current monitoring cycle. -4. **Scales to process power** — it estimates the JVM process's share of CPU power using `(processCpuLoad / systemCpuLoad) * totalCpuPower`. -5. **Attributes energy to methods** — each thread receives a fraction of process power proportional to its CPU time. Within each thread, power is further distributed to call branches proportionally to their sample counts. -6. **Writes results** to CSV files — both power (W) and energy (J = W × interval) are recorded per branch per cycle. +1. **Samples the JVM stack**: every `stack-monitoring-sample-rate` milliseconds, it captures the stack trace of every `RUNNABLE` thread, counting how often each call branch is seen. +2. **Measures CPU time**: at the start and end of each cycle, it reads the CPU time of each thread and of the whole JVM. +3. **Reads the CPU power** of the machine over the cycle, from RAPL, PowerJoular, or the host of a virtual machine. +4. **Scales to process power**: the JVM's share of the CPU power is its own CPU load over the machine's: `cpuPower * processLoad / systemLoad`. +5. **Attributes energy to methods and execution branches**: each thread receives a fraction of process power proportional to its CPU time. Within each thread, power is further distributed to execution call branches proportionally to their sample counts. +6. **Writes results** to CSV files: both power (W) and energy (J = W × interval) are recorded per branch per cycle. ## :package: Compilation and Installation ### Requirements -- Java 21 or later +- Java 21 or later, to build and to run the application being monitored - Apache Maven 3.6 or later -- [Joular Core](https://github.com/joular/joularcore) for `ringbuffer`, `csv`, or `http` power sources, or Linux powercap/RAPL access for `rapl` +- One way to measure the CPU (see [Power sources](#power-sources)): read access to Linux RAPL, [PowerJoular](https://github.com/joular/powerjoular) 2.0.0 or later, or, in a virtual machine, a host running PowerJoular (or another power monitoring tool) ### Build @@ -51,17 +51,17 @@ cd joularcode-java mvn clean install ``` -This produces a fat JAR at: +This produces a JAR at: ``` target/joularcodejava-.jar ``` -The JAR bundles all dependencies (including JNA) via the Maven Shade plugin, so no additional classpath setup is needed. +The agent has no runtime dependencies, so the JAR holds nothing but its own classes and needs no additional classpath setup. ## :bulb: Usage -Joular Code - Java attaches to your Java application as a Java agent. Start [Joular Core](https://github.com/joular/joularcore) first when using `ringbuffer`, `csv`, or `http`; on Linux, `rapl` can read CPU package power directly from the powercap interface. +Joular Code for Java attaches to your Java application as a Java agent, either on the command line with `-javaagent:` or to an already running JVM. On Linux it reads the CPU power from RAPL directly, and elsewhere (including Linux), start [PowerJoular](https://github.com/joular/powerjoular) with `-r` option (to export power data to a ring buffer), see [Power sources](#power-sources). ### Basic usage @@ -75,9 +75,23 @@ java -javaagent:joularcodejava-.jar YourMainClass java -javaagent:joularcodejava-.jar -jar yourApplication.jar ``` +### Attaching to a JVM that is already running + +The agent can also be loaded into a running JVM through the Attach API, without restarting it: + +```java +VirtualMachine vm = VirtualMachine.attach(pid); +vm.loadAgent("/path/to/joularcodejava-.jar"); +vm.detach(); +``` + +Monitoring covers the JVM from the moment it attaches, so whatever the application did before that is not in the results. JDK 21 and later allow the attach by default but print a warning: start the target JVM with `-XX:+EnableDynamicAgentLoading` to silence it, or with `-XX:-EnableDynamicAgentLoading` to forbid it. + +Configuration is read when the agent attaches, so `-Djoularcodejava.properties=...` belongs on the target JVM's own command line. Attaching a second time is ignored, with a warning, rather than starting a second monitor. + ### Specifying a custom configuration file -By default, Joular Code - Java looks for `joularcodejava.properties` in the current working directory. To specify a custom path: +By default, Joular Code for Java looks for `joularcodejava.properties` in the current working directory. To specify another path: ```bash java -Djoularcodejava.properties=/path/to/joularcodejava.properties -javaagent:joularcodejava-.jar -jar yourApplication.jar @@ -85,66 +99,76 @@ java -Djoularcodejava.properties=/path/to/joularcodejava.properties -javaagent:j ## :gear: Configuration -All configuration is done via a `joularcodejava.properties` file. Joular Code - Java loads it from the following locations in order: +All configuration is done via a `joularcodejava.properties` file, read from: + +1. The path given with the `-Djoularcodejava.properties=` JVM property +2. or `joularcodejava.properties` in the current working directory -1. Path specified by the `-Djoularcodejava.properties=` JVM property -2. `joularcodejava.properties` in the current working directory -3. Built-in defaults (bundled inside the JAR) +A missing file, or a property left empty, means the default value will be used. [joularcodejava.properties.example](joularcodejava.properties.example) is a commented example template you can use. ### Configuration properties | Property | Default | Description | |---|---|---| -| `power-source-type` | `ringbuffer` | Power data backend: `ringbuffer`, `csv`, `http`, or `rapl` | -| `joular-core-ringbuffer-path` | OS-dependent | Path to the Joular Core ring buffer (see below) | -| `joular-core-csv-path` | `joularcore-data.csv` | Path to the Joular Core CSV output file | -| `joular-core-http-url` | `http://localhost:8080/data` | URL of the Joular Core HTTP endpoint | -| `stack-monitoring-sample-rate` | `10` | Stack sampling interval in milliseconds. Lower = more accurate but higher overhead | +| `power-source-type` | `auto` | Where the CPU power comes from: `auto`, `rapl`, `ringbuffer` or `vm` | +| `powerjoular-ringbuffer-path` | OS-dependent | Path to the PowerJoular ring buffer (see below) | +| `vm-power-file` | *(empty)* | In a virtual machine, the file the host writes hte power of the guest virtual machine (see below) | +| `vm-power-format` | `powerjoular` | Format of `vm-power-file`: `powerjoular` or `watts` | +| `stack-monitoring-sample-rate` | `10` | Stack sampling interval in milliseconds, from 1 to 1000. Lower = more accurate but higher overhead | | `results-path` | `joular-code-java-results` | Directory where CSV result files are written | | `methods-filtering-prefix` | *(empty)* | Comma-separated list of package/class prefixes to filter app-specific methods (e.g., `com.example,org.myapp`) | -### Power source backends +### Power sources -#### Ring buffer (recommended) +With `power-source-type=auto`, the default, the agent reads RAPL on Linux, and the PowerJoular ring buffer everywhere else, including Linux machines without RAPL such as the Raspberry Pi. On Linux, `ringbuffer` keeps the application unprivileged: run `sudo powerjoular -r` and set it explicitly. In a virtual machine, set `vm`. -The ring buffer backend reads power data from a POSIX shared memory or Windows named shared memory object written by Joular Core. This is the lowest-latency option and has minimal overhead. +#### Linux RAPL (`rapl`) -Default paths by OS: -- **Linux**: `/dev/shm/joularcorering` -- **macOS**: `/tmp/joularcorering` -- **Windows**: `Local\JoularCoreRing` +Reads the energy counters of the CPU packages directly from `/sys/class/powercap/intel-rapl:N`, Intel and AMD alike, adding up every package of a machine with several sockets. Nothing else is needed, but `energy_uj` is only readable by root on most distributions: run the application as root, give its user read access to those files (for example with a udev rule), or run PowerJoular with `-r` as root and set `power-source-type=ringbuffer`. PowerJoular reads the first package only, so on a machine with several sockets `rapl` and `ringbuffer` do not give the same figure. ```properties -power-source-type=ringbuffer -joular-core-ringbuffer-path=/dev/shm/joularcorering +power-source-type=rapl ``` -#### CSV file +#### PowerJoular ring buffer (`ringbuffer`) -Joular Core can be configured to write power data to a CSV file. Joular Code - Java reads the latest row from that file each monitoring cycle. Use this when IPC is unavailable. The parser expects Joular Core's simple comma-separated output where the first column is a numeric timestamp and the second column is CPU power in watts. +Reads the shared memory area [PowerJoular](https://github.com/joular/powerjoular) writes with `-r`. PowerJoular measures the hardware through [Joular Core](https://github.com/joular/joularcore) (RAPL on Windows, powermetrics on Macs, where PowerJoular has to run with `sudo`, power models on Raspberry Pi), and runs as a process of its own, so the privileges needed to measure the hardware stay out of the Java application. Each monitoring cycle ends as PowerJoular publishes a measurement, so the stack samples and the power describe the same second. PowerJoular may be started before or after the application, and restarted while it runs, except on Windows: a mapped file cannot be deleted there, so PowerJoular has to be started before the application and not restarted while it runs. + +Default paths by OS: +- **Linux**: `/dev/shm/powerjoular` +- **macOS**: `/tmp/powerjoular` +- **Windows**: `%PROGRAMDATA%\powerjoular` ```properties -power-source-type=csv -joular-core-csv-path=/path/to/joularcore-data.csv +power-source-type=ringbuffer +powerjoular-ringbuffer-path=/dev/shm/powerjoular ``` -#### HTTP endpoint +#### Virtual machine (`vm`) -Reads power data from a trusted JSON HTTP endpoint exposed by Joular Core. The response must be a JSON object containing a top-level numeric `cpu_power` field. Suitable for remote or containerized deployments when the endpoint is controlled by you or your infrastructure. +The CPU of a virtual machine cannot be measured from inside it, but its host can measure the process the virtual machine runs as. Run PowerJoular on the host for that process, share the file it writes with the virtual machine (virtiofs, 9p, a shared folder), and point the agent at it. Nothing else has to run in the virtual machine. -```properties -power-source-type=http -joular-core-http-url=http://localhost:8080/data -``` +On the host, `-p` with `-o` writes two files: `` for the whole host, and `-.csv` for the process, which is the one to share: -#### Linux RAPL powercap +```bash +powerjoular -p -o /shared/vm-power.csv +``` -Reads CPU package power directly from the Linux powercap RAPL PKG domain at `/sys/class/powercap/intel-rapl/intel-rapl:0`. This source is Linux-only, uses `energy_uj` and `max_energy_range_uj`, and supports the PKG `package-0` domain only. +In the virtual machine: ```properties -power-source-type=rapl +power-source-type=vm +vm-power-file=/mnt/shared/vm-power.csv-.csv +vm-power-format=powerjoular ``` +Only the first line of the file is read, in one of the two formats: + +- `powerjoular` (the default): the row PowerJoular rewrites every second with `-o` for a process, `timestamp,cpu_usage,cpu_power`. The file of the whole host, with five columns, is refused, since it would charge all of the host's power to one virtual machine. The host writes on its own one-second cycle, so a cycle may see the row the previous one saw, or catch the file while it is rewritten: the last row covers up to two more cycles, after which the host is taken to have stopped and no energy is attributed until it writes again. +- `watts`: the power alone, in watts, written by any tool. The value is used as it is, however long ago it was written. + +Some shared folders cache files in the guest (9p with `cache=loose`, virtiofs with `cache=always`), which can hide the host's updates: mount the share without caching. + ### Method filtering The `methods-filtering-prefix` property controls which methods appear in the `methods-power-app.csv` output. Provide one or more comma-separated package or class name prefixes: @@ -157,22 +181,21 @@ methods-filtering-prefix=com.example,org.myapp - When set, `methods-power-app.csv` only contains branches where at least one stack element matches a prefix. Energy from unmatched (e.g., JDK) frames that are called by a matched method is attributed upward to the matched method. - `methods-power-all.csv` always contains every observed branch regardless of this setting. - ## :bar_chart: Generated Files -Joular Code - Java writes results into the directory configured by `results-path`. Two CSV files are produced and appended to during execution: +Joular Code for Java writes results into the directory configured by `results-path`. Two CSV files are produced and appended to during execution: | File | Contents | |---|---| -| `methods-power-all.csv` | Power and energy for all observed call branches, including JDK internals | -| `methods-power-app.csv` | Power and energy for call branches filtered to your application packages | +| `methods-power-all.csv` | Power and energy for all observed call branches, including JDK ones | +| `methods-power-app.csv` | Power and energy for call branches filtered to your application's packages and methods | ### CSV format -Both files share the same schema: +Both files share the same format: ``` -timestamp,branch,power_watts,energy_joules,interval_seconds +timestamp,branch,power_watts,energy_joules,interval_seconds,coverage ``` | Column | Type | Description | @@ -182,36 +205,39 @@ timestamp,branch,power_watts,energy_joules,interval_seconds | `power_watts` | double | Estimated power consumed by this branch during the cycle (W) | | `energy_joules` | double | Energy = power_watts × interval_seconds (J) | | `interval_seconds` | double | Duration of the monitoring cycle (s), typically ~1.0 | +| `coverage` | double | The share of the JVM's CPU time during the cycle that belonged to threads the agent actually sampled, from 0.0 to 1.0 | + +#### What `coverage` means + +Power is split between threads by the CPU time each one used, and the denominator is every thread that used CPU, not only the ones that were caught in a sample. +Power drawn by a thread the agent never sampled is therefore left unattributed rather than shared out over the threads it did see. +`coverage` says how much of the JVM's CPU time is represented: at `1.0` everything was accounted for, and at `0.6` only 60% of what the JVM consumed are attributed to the observed threads at that timestamp. +Threads that are created and destroyed inside a single cycle are invisible here, because the JVM stops reporting a thread's CPU time once it has ended. ### Example output ``` -timestamp,branch,power_watts,energy_joules,interval_seconds -1746000000000,com.example.Main.main;com.example.Worker.compute,2.341500000,2.341500000,1.000000000 -1746000001000,com.example.Main.main;com.example.Worker.compute,2.158300000,2.158300000,1.000000000 +timestamp,branch,power_watts,energy_joules,interval_seconds,coverage +1746000000000,com.example.Main.main;com.example.Worker.compute,2.341500000,2.341500000,1.000000000,1.0000 +1746000001000,com.example.Main.main;com.example.Worker.compute,2.158300000,2.158300000,1.000000000,0.9974 ``` -## :warning: Troubleshooting - -- **"Joular Core ring buffer appears stale"** (`WARNING` log): Joular Core has stopped advancing the ring buffer, so confirm that the Joular Core process is still running and still writing data. Until that resumes, Joular Code - Java gives a power value of `0.0`. -- **"Could not read power data from CSV: ..."** (`WARNING` log): the configured `joular-core-csv-path` does not exist. Check that Joular Core is running in CSV export mode and writing to the same path. The warning is logged only once until the file becomes available again. -- **HTTP mode returns 0.0 power**: the HTTP endpoint must return a JSON object containing a `"cpu_power": ` key directly on the top-level object (not nested). Keys like `"last_cpu_power"` or `"cpu_power_limit"` are ignored. -- **RAPL mode fails at startup**: `power-source-type=rapl` is Linux-only and requires readable `/sys/class/powercap/intel-rapl/intel-rapl:0/name`, `energy_uj`, and `max_energy_range_uj` files for the `package-0` domain. Depending on the system, this may require elevated permissions. -- **No rows in `methods-power-app.csv`**: if `methods-filtering-prefix` is set, the configured prefix may not match any fully-qualified method name in your application. If it is unset, all observed methods are eligible for both output files. +With RAPL, a cycle lasts one second, and the energy is read at its very end, so the power and the stack samples cover the same time. In a virtual machine, a cycle lasts one second too, and gets the latest power the host wrote. With the ring buffer, a cycle ends when PowerJoular publishes a measurement, or after two seconds without one, in which case that cycle gets no rows. ## :information_source: Notes -- Joular Code - Java requires `com.sun.management.OperatingSystemMXBean` to measure process and system CPU load. This is available in all standard HotSpot JVMs (OpenJDK, Oracle JDK). Minimal or embedded JVMs that do not provide this class are not supported. -- Thread CPU time attribution requires `ThreadMXBean.isThreadCpuTimeSupported()` to return `true`. If it does not, Joular Core - Java will fail. +- Joular Code for Java requires `com.sun.management.OperatingSystemMXBean` to measure process and system CPU load. This is available in all standard HotSpot JVMs (OpenJDK, Oracle JDK). Minimal or embedded JVMs that do not provide this class are not supported. +- Thread CPU time attribution requires `ThreadMXBean.isThreadCpuTimeSupported()` to return `true`. If it does not, Joular Code for Java does not start, and the application runs unmonitored. +- The agent never follows a symbolic link when it opens its results files or the ring buffer, since it may run as root and these often sit in directories every user can write to. +- When the application runs as root, give the configuration file with `-Djoularcodejava.properties` rather than leaving it in a working directory others can write to, since it chooses where the results are written. - The agent's own monitoring thread is excluded from all energy measurements. - Power values of `0.0` are suppressed in the output (rows with zero power are not written). -- The `NO_COLOR` environment variable disables ANSI color output in the agent banner. ## :newspaper: License -Joular Code - Java is licensed under the GNU LGPL 3 license only (LGPL-3.0-only). +Joular Code for Java is licensed under the GNU LGPL 3 license only (LGPL-3.0-only). -Copyright © 2025-2026, Adel Noureddine. +Copyright © 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)](https://www.gnu.org/licenses/lgpl-3.0.en.html) which accompanies this distribution. Author: Prof. Adel Noureddine diff --git a/joularcodejava.properties.example b/joularcodejava.properties.example new file mode 100644 index 0000000..a47b544 --- /dev/null +++ b/joularcodejava.properties.example @@ -0,0 +1,55 @@ +# 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 for Java: Configuration +# +# CPU power source +# Options: +# auto (RAPL on Linux, the PowerJoular ring buffer everywhere else, including Linux machines without RAPL such as the Raspberry Pi) +# rapl (read Linux RAPL directly, from /sys/class/powercap) +# ringbuffer (read the shared memory ring buffer PowerJoular writes with -r) +# vm (inside a virtual machine, read the file the host writes the power of this virtual machine to, see vm-power-file) +# Default if left empty: auto +power-source-type= + +# Path to the ring buffer (used by ringbuffer, and by auto on a machine without RAPL) +# Default if left empty (OS-dependent): +# Linux: /dev/shm/powerjoular +# macOS: /tmp/powerjoular +# Windows: %PROGRAMDATA%\powerjoular +powerjoular-ringbuffer-path= + +# The path to the file where the host writes the power consumption of the guest virtual machine (if power-source-type is vm), as seen from the virtual machine +# On the host, powerjoular -p -o writes it to -.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: - *

    - *
  1. The classpath resource {@code joularcodejava.properties} (all values - * intentionally empty so the defaults are defined in code, not in the file).
  2. - *
  3. The file path pointed to by the system property - * {@code joularcodejava.properties} (if set).
  4. - *
  5. A {@code joularcodejava.properties} file in the working directory - * (only consulted if the system property is absent).
  6. - *
- * - *

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()); - } -}