Debug OpenHAB GraalJS automation rules (.js files in automation/js/) from VS Code using the Chrome DevTools Protocol over port 9229.
Verified working on: OpenHAB 5.1.3, OpenJDK 21.0.10, GraalJS 25.0.1, Debian 13 (trixie) aarch64, Equinox OSGi, Karaf 4.4.8. Date: 2026-03-22.
OpenHAB ships GraalJS but not the debug instrument. You supply it. Four steps:
-
Download 3 JARs, patch their manifest. The JARs (
chromeinspector-tool,profiler-tool,json) are plain Maven artifacts. They need one manifest header added (Fragment-Host: org.openhab.osgiify.org.graalvm.truffle.truffle-api) so OSGi merges them into the Truffle classloader. A Python script does this automatically — downloads, patches, deploys to/openhab/addons/:python3 wrap_graal_fragments.py --deploy /opt/openhab/addons
-
Copy a launch config to VS Code. Paste this into
.vscode/launch.json:{ "type": "node", "request": "attach", "name": "Attach to OpenHAB GraalJS", "address": "<YOUR_OPENHAB_IP>", "port": 9229, "localRoot": "${workspaceFolder}/automation/js", "remoteRoot": "/openhab/conf/automation/js", "restart": true, "continueOnAttach": true } -
Expose port 9229 and add JVM flags (Docker only). In
docker-compose.yaml:ports: - "9229:9229" environment: - EXTRA_JAVA_OPTS=...existing... -Dpolyglot.inspect=0.0.0.0:9229 -Dpolyglot.inspect.Suspend=false -Dpolyglot.inspect.WaitAttached=false -Dpolyglot.inspect.Secure=false
-
Restart.
docker compose stop openhab && docker compose rm -f openhab && docker compose up -d openhab
That's it. Trigger any JS rule, then press ▶ in VS Code's debug panel. The rest of this document explains why each step is needed and what to do when something goes wrong.
Host Machine (VS Code) RPi 192.168.1.30
┌──────────────────────┐ ┌─────────────────────────────────────┐
│ VS Code │ │ Docker: openhab │
│ Built-in JS Debugger│ TCP :9229 │ OpenJDK 21.0.10 │
│ │ ── WebSocket ────> │ GraalJS 25.0.1 (Polyglot API) │
│ launch.json: │ │ chromeinspector (Fragment bundle) │
│ type: "node" │ <── vars/stack ── │ │
│ request: "attach" │ │ Truffle Engine (static singleton) │
│ port: 9229 │ │ └─ JS Context per rule │
│ │ │ └─ automation/js/*.js │
└──────────────────────┘ └─────────────────────────────────────┘
VS Code's built-in Node.js debugger speaks the Chrome DevTools Protocol natively and connects directly to the GraalVM chromeinspector WebSocket endpoint. No extra extensions needed.
OpenHAB ships GraalJS 25.0.1 as OSGi bundles but does not include the chromeinspector-tool instrument JAR. Without it, the -Dpolyglot.inspect JVM flag causes an immediate fatal error — GraalJS cannot find the inspector instrument and throws an ExceptionInInitializerError in a static initializer, permanently breaking JS scripting for the entire JVM lifetime.
The instrument must be delivered as an OSGi Fragment-Host bundle that attaches to truffle-api, making its META-INF/services/TruffleInstrumentProvider discoverable via ServiceLoader.load(..., truffleClassLoader). Plain JARs on the classpath or boot classpath are invisible to this call because the Truffle ServiceLoader is scoped to the truffle-api bundle's OSGi classloader.
Finally, the chromeinspector defaults to TLS mode and requires a keystore. Without -Dpolyglot.inspect.Secure=false, it fails with Starting inspector on 0.0.0.0:9229 failed: Use options to specify the keystore.
- SSH access to the Raspberry Pi
- VS Code with the built-in JavaScript debugger (no extensions needed)
- Python 3 (for patching JARs — one-time step)
Three JARs are required, all version 25.0.1 to match the GraalJS embedded in OpenHAB:
| JAR | Maven Artifact | Size |
|---|---|---|
chromeinspector-tool-25.0.1.jar |
org.graalvm.tools:chromeinspector-tool |
1.1 MB |
profiler-tool-25.0.1.jar |
org.graalvm.tools:profiler-tool |
507 KB |
json-25.0.1.jar |
org.graalvm.shadowed:json |
177 KB |
The profiler-tool and json JARs are transitive dependencies of chromeinspector-tool. All three must be present.
The included Python script downloads these JARs from Maven Central, patches their META-INF/MANIFEST.MF with OSGi Fragment-Host headers, and deploys them to the OpenHAB addons directory — all in one step:
cd /path/to/OH_JS_Debug/fragments
python3 wrap_graal_fragments.py --deploy /opt/openhab/addonsThis creates fragment bundles (e.g., chromeinspector-tool-fragment-25.0.1.jar) that are identical to the originals except the manifest now declares Fragment-Host: org.openhab.osgiify.org.graalvm.truffle.truffle-api. Equinox resolves these fragments and merges their contents (classes + META-INF/services/*) into the truffle-api bundle's classloader.
No separate "original" JARs are needed. The fragment bundles contain all the same class files and ServiceLoader entries — only the manifest differs. There is no need for lib/boot/ bind-mounts or boot classpath manipulation.
To use a different GraalJS version:
python3 wrap_graal_fragments.py --version 25.1.0 --deploy /opt/openhab/addonsBack up first:
cp /home/pi/docker-compose.yaml /home/pi/docker-compose.yaml.backup_graal_debug_$(date +%Y%m%d)Edit /home/pi/docker-compose.yaml — two changes to the openhab service:
ports:
- "8080:8080"
- "8443:8443"
- "9229:9229" # GraalJS Chrome DevTools inspectorAppend these four flags to the existing EXTRA_JAVA_OPTS:
-Dpolyglot.inspect=0.0.0.0:9229
-Dpolyglot.inspect.Suspend=false
-Dpolyglot.inspect.WaitAttached=false
-Dpolyglot.inspect.Secure=false
All four are required:
| Flag | Value | Purpose |
|---|---|---|
polyglot.inspect |
0.0.0.0:9229 |
Enable the inspector and bind to all interfaces on port 9229 |
polyglot.inspect.Suspend |
false |
Don't pause at startup waiting for a debugger to attach |
polyglot.inspect.WaitAttached |
false |
Don't block rule execution when no debugger is connected |
polyglot.inspect.Secure |
false |
Disable TLS — chromeinspector defaults to TLS and requires a keystore; without this flag it throws a fatal PolyglotException |
The GraalVM Polyglot API reads polyglot.* system properties as engine options. This works because OpenhabGraalJSScriptEngine creates the shared Engine with .allowExperimentalOptions(true).
Complete EXTRA_JAVA_OPTS example:
environment:
- EXTRA_JAVA_OPTS=-Duser.timezone=Europe/Berlin -Djava.net.preferIPv4Stack=true -Xms512m -Xmx1024m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/openhab/userdata/ -XX:+ExitOnOutOfMemoryError -XX:InitiatingHeapOccupancyPercent=30 -Dpolyglot.inspect=0.0.0.0:9229 -Dpolyglot.inspect.Suspend=false -Dpolyglot.inspect.WaitAttached=false -Dpolyglot.inspect.Secure=falseAlternatively, use the included setup script to patch docker-compose.yaml automatically:
./setup-docker-compose.sh # or --dry-run to previewA clean stop/remove/up cycle ensures the container picks up all docker-compose changes and Equinox rebuilds its cache with the fragments:
cd /home/pi
docker compose stop openhab
docker compose rm -f openhab
docker compose up -d openhabWait approximately 60 seconds for startup to complete.
docker exec openhab bash -c "cat /proc/\$(pgrep -f 'openhab.*java' | head -1)/cmdline | tr '\0' '\n' | grep polyglot"Expected output (4 lines):
-Dpolyglot.inspect=0.0.0.0:9229
-Dpolyglot.inspect.Suspend=false
-Dpolyglot.inspect.WaitAttached=false
-Dpolyglot.inspect.Secure=false
docker exec openhab sh -c "printf 'bundle:list\nlogout\n' | /openhab/runtime/bin/client -p habopen" | grep 289Expected: 289 │ Active │ 80 │ 5.1.3 │ openHAB Add-ons :: Bundles :: A...
If it shows Waiting instead of Active, see the Troubleshooting section.
docker exec openhab sh -c "printf 'bundle:list\nlogout\n' | /openhab/runtime/bin/client -p habopen" | grep -i 'frag\|chrome.*frag\|profiler.*frag\|json.*frag'Expected: three rows for the fragments in Resolved state, and the truffle-api row shows Frag after its name:
284 │ Active │ 79 │ 25.0.1 │ GraalVM :: Truffle :: API, Frag
349 │ Resolved │ 80 │ 25.0.1 │ GraalVM Profiler Fragment, Host
350 │ Resolved │ 80 │ 25.0.1 │ GraalVM Shadowed JSON Fragment,
351 │ Resolved │ 80 │ 25.0.1 │ GraalVM Chromeinspector Fragmen
Resolved is the correct state for fragment bundles — they cannot be Active because they have no activator; they exist only to contribute resources to their host bundle.
grep -c 'GraalJSScript.*Error\|jsscripting.*Error' /opt/openhab/userdata/logs/openhab.log || echo "0 errors"Port 9229 opens lazily — only when the first JS rule fires and creates a Polyglot Context. Trigger any JS rule:
# List available rules:
curl -s "http://192.168.1.30:8080/rest/rules?summary=true&limit=5" \
-H "Authorization: Bearer <YOUR_API_TOKEN>" | \
python3 -c "import sys,json; [print(r['uid']) for r in json.load(sys.stdin)]"
# Trigger one:
curl -s -X POST "http://192.168.1.30:8080/rest/rules/<UID>/runnow" \
-H "Authorization: Bearer <YOUR_API_TOKEN>"Then verify the inspector:
curl -s --max-time 3 http://192.168.1.30:9229/json/versionExpected response:
{"Protocol-Version":"1.2","Browser":"GraalVM"}You can also run the included verification script:
./verify-setup.sh --api-token <YOUR_API_TOKEN> --trigger-rule <RULE_UID>Add this configuration to /opt/openhab/conf/.vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "Attach to OpenHAB GraalJS (port 9229)",
"address": "192.168.1.30",
"port": 9229,
"localRoot": "${workspaceFolder}/automation/js",
"remoteRoot": "/openhab/conf/automation/js",
"sourceMaps": false,
"restart": true,
"timeout": 30000,
"continueOnAttach": true
}
]
}| Field | Value | Purpose |
|---|---|---|
type |
node |
Use VS Code's built-in Node.js / Chrome DevTools debugger |
request |
attach |
Connect to an already-running process (not launch) |
address |
192.168.1.30 |
RPi's IP address |
port |
9229 |
The chromeinspector WebSocket port |
localRoot |
${workspaceFolder}/automation/js |
Local path to JS rules in the VS Code workspace |
remoteRoot |
/openhab/conf/automation/js |
Container path where OpenHAB reads JS rules |
restart |
true |
Auto-reconnect if the debug session drops (e.g., context recycled) |
continueOnAttach |
true |
Don't pause execution when the debugger first connects |
Source mapping: localRoot ↔ remoteRoot maps file breakpoints. automation/js/rules/myRule.js in your workspace maps to /openhab/conf/automation/js/rules/myRule.js in the container.
Or use the included script:
./patch-launch-json.sh- Ensure a JS rule has fired at least once since the last restart (so port 9229 is open)
- In VS Code, open the Run and Debug panel (Ctrl+Shift+D)
- Select "Attach to OpenHAB GraalJS (port 9229)" from the dropdown
- Click ▶ — status bar turns orange, showing "Debugger attached"
- Open a
.jsfile underautomation/js/and set a breakpoint (click the gutter) - Trigger the rule — execution will pause at the breakpoint
- Use the debug toolbar to step over, step into, inspect variables, evaluate expressions
File-based rules (.js files in automation/js/) reuse their Polyglot Context across invocations via a locking mechanism. This means breakpoints persist across multiple rule fires without needing to re-attach.
Inline rules (created via the UI) create a new Context per invocation. The restart: true setting in launch.json handles the reconnection transparently.
After setup, these files exist on disk:
| Path | Purpose |
|---|---|
/opt/openhab/addons/chromeinspector-tool-fragment-25.0.1.jar |
OSGi fragment bundle (deployed) |
/opt/openhab/addons/profiler-tool-fragment-25.0.1.jar |
OSGi fragment bundle (deployed) |
/opt/openhab/addons/json-fragment-25.0.1.jar |
OSGi fragment bundle (deployed) |
/opt/openhab/conf/.vscode/launch.json |
VS Code debug configuration |
/home/pi/docker-compose.yaml |
Modified: port 9229, polyglot flags |
/home/pi/docker-compose.yaml.backup_graal_debug_* |
Backup before changes |
The fragment JARs are identical to the Maven originals except for META-INF/MANIFEST.MF, which adds Fragment-Host: org.openhab.osgiify.org.graalvm.truffle.truffle-api. This tells Equinox to merge each fragment's contents into truffle-api's classloader, making the TruffleInstrumentProvider service entries visible to the Truffle engine's ServiceLoader.
No lib/boot/ bind-mounts or boot classpath manipulation is needed — the OSGi fragment mechanism handles both class loading and service discovery through a single classloader.
This means GraalJSScriptEngineFactory failed to activate. Use the Karaf scr:info command to see the full exception chain — the standard openhab.log truncates exceptions to one line:
docker exec openhab sh -c "printf 'scr:info \"org.openhab.automation.jsscripting.internal.GraalJSScriptEngineFactory\"\nlogout\n' | /openhab/runtime/bin/client -p habopen"Look for the deepest Caused by line. Common causes:
Use options to specify the keystore— missing-Dpolyglot.inspect.Secure=falseInstrumentationHandler...instrument not found— fragment bundles not attached totruffle-api; verify step 1 and restart withdocker compose rm -fNoClassDefFoundError: Could not initialize class OpenhabGraalJSScriptEngine— the static initializer failed once and the class is permanently broken for this JVM. Must restart the container.
Normal — the inspector opens lazily. Trigger any JS rule (see Step 4e). If the port still doesn't open after a rule has executed:
# Quick check from inside the container:
docker exec openhab bash -c "cat /proc/net/tcp6 /proc/net/tcp | awk '{print \$2}' | grep -i '240D' && echo OPEN || echo CLOSED"(9229 decimal = 240D hex)
The Karaf bin/client doesn't accept commands as arguments. Use stdin via printf:
docker exec openhab sh -c "printf 'bundle:list\nlogout\n' | /openhab/runtime/bin/client -p habopen"If OpenHAB or GraalJS is updated, the fragment bundle version must match the new truffle-api version. Check:
docker exec openhab sh -c "printf 'bundle:list\nlogout\n' | /openhab/runtime/bin/client -p habopen" | grep truffle-apiIf the version changed from 25.0.1, re-run the fragment script with the matching version:
cd /path/to/OH_JS_Debug/fragments
python3 wrap_graal_fragments.py --version <NEW_VERSION> --deploy /opt/openhab/addonsThen restart the container.
To remove all debug infrastructure and restore the original state:
# 1. Restore docker-compose backup:
cp /home/pi/docker-compose.yaml.backup_graal_debug_* /home/pi/docker-compose.yaml
# 2. Restart OpenHAB:
cd /home/pi && docker compose stop openhab && docker compose rm -f openhab && docker compose up -d openhab
# 3. Remove fragment bundles:
rm /opt/openhab/addons/*fragment*.jar
# 4. (Optional) Remove debug config from launch.jsonIf you only edited docker-compose.yaml manually instead of restoring the backup, remove these two things:
- The
-Dpolyglot.inspect*flags fromEXTRA_JAVA_OPTS - The
"9229:9229"port mapping
OpenhabGraalJSScriptEngine.java creates a shared static Engine:
private static final Engine ENGINE = Engine.newBuilder()
.allowExperimentalOptions(true)
.option("engine.WarnInterpreterOnly", "false")
.build();Each JS rule gets its own Context from this shared Engine:
GraalJSScriptEngine.create(ENGINE, Context.newBuilder("js")
.allowAllAccess(true)
.allowExperimentalOptions(true)
.option("js.nashorn-compat", "true")
.option("js.ecmascript-version", "2025")
.option("js.commonjs-require", "true")
...);No .option("inspect", ...) is passed in the code. The polyglot.inspect system property is picked up by the Engine.newBuilder() because allowExperimentalOptions(true) is set. When the first Context is created from this Engine, the chromeinspector instrument starts the WebSocket server on port 9229.
OpenHAB runs on Equinox OSGi. All GraalVM components are loaded as OSGi bundles (org.openhab.osgiify.*). The Truffle runtime discovers instruments through:
ServiceLoader.load(TruffleInstrumentProvider.class, truffleClassLoader)The truffleClassLoader is truffle-api's OSGi bundle classloader. It can only see:
- Classes and resources inside the
truffle-apibundle - Classes and resources from fragment bundles attached to it via
Fragment-Host
A Fragment-Host: org.openhab.osgiify.org.graalvm.truffle.truffle-api header tells Equinox to merge the fragment's contents into truffle-api's classloader, making META-INF/services/TruffleInstrumentProvider visible to the ServiceLoader.
Why not the boot classpath? Placing JARs in lib/boot/ adds them to the Karaf framework's application classpath, but this classpath is invisible to the truffle-api bundle's OSGi classloader. The ServiceLoader call is scoped to truffleClassLoader, not the JVM system classloader. Fragment bundles are the only mechanism that works.
No. A forensic byte-level comparison reveals that original Maven JARs and the fragment JARs are identical in every way except META-INF/MANIFEST.MF:
| Aspect | Original JAR | Fragment JAR |
|---|---|---|
| File count | 289 entries | 289 entries |
| Class files | identical | identical |
| ServiceLoader entries | identical | identical |
| MANIFEST.MF | Plain JAR headers | OSGi Fragment-Host headers |
The fragment JAR is a complete, self-contained bundle. It contains all classes, all META-INF/services/* entries, and the OSGi headers. When Equinox merges it into truffle-api's classloader, both class loading and service discovery happen through that single classloader.
Earlier versions of this guide included lib/boot/ bind-mounts as a belt-and-suspenders precaution during the original debugging process. Testing confirmed they are unnecessary and have been removed.
Equinox's bundle cache persists in /openhab/userdata/ (bind-mounted). When the container restarts:
- Equinox restores cached bundles (including fragments from the previous run)
- Fragments are resolved and attached to
truffle-apiduring the startup resolution pass truffle-apiactivates at start level 79 — with fragments already attachedGraalJSScriptEngineFactoryactivates at start level 80 — finds the chromeinspector viaServiceLoader
This means the fragments only need to be installed once. After the first run, the cache handles the ordering automatically.
The chromeinspector instrument defaults to TLS (HTTPS/WSS). When it starts, it tries to open an SSL server socket. Without a keystore configured, it throws:
org.graalvm.polyglot.PolyglotException: Starting inspector on 0.0.0.0:9229 failed: Use options to specify the keystore
This exception occurs inside the static initializer of OpenhabGraalJSScriptEngine, which sets the class to a permanent Error state in the JVM. Every subsequent attempt to use the class throws NoClassDefFoundError. The only recovery is restarting the container.
-Dpolyglot.inspect.Secure=false disables TLS, using plain HTTP/WS instead. This is acceptable for a local development network.