Skip to content

s0170071/OH_JS_Debug

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

GraalJS Remote Debugging — OpenHAB on Docker/RPi

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.

DISCLAIMER: The scripts work on my setup (docker). Use at your own risk.

TL;DR

OpenHAB ships GraalJS but not the debug instrument. You supply it. Four steps:

  1. 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
  2. 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
    }
  3. 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
  4. 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.

image

Architecture

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.


Why This Isn't Trivial

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.


Prerequisites

  • 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)

Step 1 — Download and Patch the Fragment Bundles

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/addons

This 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/addons

Step 2 — Update docker-compose.yaml

Back 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:

2a. Expose port 9229

ports:
  - "8080:8080"
  - "8443:8443"
  - "9229:9229"       # GraalJS Chrome DevTools inspector

2b. Add polyglot JVM flags to EXTRA_JAVA_OPTS

Append 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=false

Alternatively, use the included setup script to patch docker-compose.yaml automatically:

./setup-docker-compose.sh        # or --dry-run to preview

Step 3 — Restart OpenHAB

A 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 openhab

Wait approximately 60 seconds for startup to complete.


Step 4 — Verify

4a. JVM flags are present

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

4b. JS scripting bundle is Active

docker exec openhab sh -c "printf 'bundle:list\nlogout\n' | /openhab/runtime/bin/client -p habopen" | grep 289

Expected: 289 │ Active │ 80 │ 5.1.3 │ openHAB Add-ons :: Bundles :: A...

If it shows Waiting instead of Active, see the Troubleshooting section.

4c. Fragment bundles are Resolved and attached

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.

4d. Zero JS scripting errors

grep -c 'GraalJSScript.*Error\|jsscripting.*Error' /opt/openhab/userdata/logs/openhab.log || echo "0 errors"

4e. Inspector is reachable (after first JS rule runs)

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/version

Expected 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>

Step 5 — VS Code launch.json

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: localRootremoteRoot 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

Using the Debugger

  1. Ensure a JS rule has fired at least once since the last restart (so port 9229 is open)
  2. In VS Code, open the Run and Debug panel (Ctrl+Shift+D)
  3. Select "Attach to OpenHAB GraalJS (port 9229)" from the dropdown
  4. Click ▶ — status bar turns orange, showing "Debugger attached"
  5. Open a .js file under automation/js/ and set a breakpoint (click the gutter)
  6. Trigger the rule — execution will pause at the breakpoint
  7. 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.


File Inventory

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.


Troubleshooting

Bundle 289 shows Waiting instead of Active

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=false
  • InstrumentationHandler...instrument not found — fragment bundles not attached to truffle-api; verify step 1 and restart with docker compose rm -f
  • NoClassDefFoundError: Could not initialize class OpenhabGraalJSScriptEngine — the static initializer failed once and the class is permanently broken for this JVM. Must restart the container.

Port 9229 not listening after startup

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)

Karaf console commands don't work

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"

After OpenHAB update to a new version

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-api

If 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/addons

Then restart the container.


Rollback

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.json

If you only edited docker-compose.yaml manually instead of restoring the backup, remove these two things:

  • The -Dpolyglot.inspect* flags from EXTRA_JAVA_OPTS
  • The "9229:9229" port mapping

Technical Details

How GraalJS Creates the Engine

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.

Why Fragment Bundles (Not Boot Classpath)

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-api bundle
  • 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.

Original JARs vs Fragment JARs — Are Both Needed?

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.

Bundle Cache and Timing

Equinox's bundle cache persists in /openhab/userdata/ (bind-mounted). When the container restarts:

  1. Equinox restores cached bundles (including fragments from the previous run)
  2. Fragments are resolved and attached to truffle-api during the startup resolution pass
  3. truffle-api activates at start level 79 — with fragments already attached
  4. GraalJSScriptEngineFactory activates at start level 80 — finds the chromeinspector via ServiceLoader

This means the fragments only need to be installed once. After the first run, the cache handles the ordering automatically.

The Secure=false Requirement

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.

About

GraalJS Remote Debugging for OpenHAB 5 on Docker — setup scripts and guide

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors