Skip to content

fix(launcher): honour JAVA_OPTS in the native distribution - #72

Merged
satran004 merged 3 commits into
mainfrom
fix/native-java-opts-warning
Aug 18, 2026
Merged

fix(launcher): honour JAVA_OPTS in the native distribution#72
satran004 merged 3 commits into
mainfrom
fix/native-java-opts-warning

Conversation

@satran004

Copy link
Copy Markdown
Member

Problem

yano.sh forwarded JAVA_OPTS only on the jar path. Both native branches dropped it
silently, so a working jar deployment moved to the native distribution loses its settings
with no message.

This is not theoretical — it cost a full test run. The native node was started with
JAVA_OPTS="-Xmx6g -Dquarkus.http.port=7071 ...", the port property was discarded, the node
bound the default 7070, and the health check timed out against a node that was in fact
perfectly healthy (it had produced 2,863 blocks and shut down cleanly).

The usage text also claimed JAVA_OPTS was "JVM options for jar distribution only".

Change

JAVA_OPTS is now honoured by both distributions. The split is based on what the native
binary actually accepts, established by probing the built image rather than assumed:

flag native binary handling
-Xmx512m, -Xms256m accepted forwarded
-Dfoo=bar accepted forwarded
-verbose:gc accepted forwarded
-XX:+UseG1GC rejectederror: Could not find option 'UseG1GC' dropped + warned
-XX:MaxRAMPercentage=50 rejected dropped + warned
-javaagent:, -agentlib:, --add-* no native equivalent dropped + warned

HotSpot -XX: flags are dropped rather than forwarded deliberately. A GraalVM image has
its own -XX: namespace, so passing HotSpot tuning through would turn a silent
misconfiguration into a hard startup failure — strictly worse than the current behaviour. The
warning names the exact entries dropped, so nothing disappears silently in either direction.

The namespaces really do differ, which is the justification for the split — from the image's
own option table:

-XX:MaxHeapSize=0                 The maximum heap size at run-time, in bytes.
-XX:MinHeapSize=0                 The minimum heap size at run-time, in bytes.
-XX:MaximumHeapSizePercent=80     The maximum heap size as percent of physical memory.

MaximumHeapSizePercent exists where HotSpot's MaxRAMPercentage does not.

Scope: app/bin/yano.sh only. No production Java touched.

Verification

Launcher logic (stub binary, so the assembled argv is visible):

A. no JAVA_OPTS
   ARGS: -Dyano.block-producer.script-evaluator=scalus -Dquarkus.profile=devnet

B. JAVA_OPTS="-Xmx6g -Dquarkus.http.port=7071 -Dyano.server.port=30001"
   ARGS: ... -Xmx6g -Dquarkus.http.port=7071 -Dyano.server.port=30001 ...

C. JAVA_OPTS="-Xmx4g -XX:+UseG1GC -javaagent:/tmp/a.jar -Dfoo=bar"
   Warning: the native binary cannot accept these JAVA_OPTS entries:
           -XX:+UseG1GC -javaagent:/tmp/a.jar
   ARGS: ... -Xmx4g -Dfoo=bar ...

Values are honoured, not merely passed. Both flag classes were A/B tested by changing the
value and looking for a changed outcome.

-D properties — starting with JAVA_OPTS="-Dquarkus.http.port=7071" alone moves the
listener off its configured 7070 default on both distributions. On native this is exactly the
case that failed before.

-Xmx — same workload (300 faucet calls), only the flag changed:

distribution -Xmx6g -Xmx24m
Native RSS 132 MB RSS 88 MB (−44 MB), starts, no OOM
JVM RSS 304 MB fails to start, OutOfMemoryError

Both react, so the flag is genuinely applied. The asymmetry is expected: native tolerates a
24 MB Java heap where the JVM cannot, because much of a native image's footprint is off-heap
(RocksDB, image data) with far less JVM overhead.

No regression — full load and SDK suite re-run against both distributions after the change
(3 min CCL + 90 s MeshJS + 90 s Evolution + contract tests each):

metric JVM Native
CCL regular 50,398 / 50,398 (100%) @ 279.9 tx/s 43,277 / 43,277 (100%) @ 239.9 tx/s
CCL chains (depth 8) 1,216 / 1,216 full depth 1,101 / 1,101 full depth
MeshJS regular 12,575 / 12,575 (100%) 7,366 / 7,366 (100%)
Evolution chains 24/32 full depth 24/32 full depth
MeshJS / Evolution vesting (PlutusV3 datum+redeemer) 3/3 / 3/3 3/3 / 3/3
node ERROR / WARN 0 / 0 0 / 0

JVM CCL throughput went 276.9 → 279.9 tx/s versus the pre-change baseline. The change only
touches argument assembly in a shell script and cannot affect node behaviour.

bash -n clean; the launcher targets bash 3.2 (macOS default) and uses no newer syntax.

Notes

Unrelated items observed during the same testing and deliberately not addressed here:

yano.sh forwarded JAVA_OPTS only on the jar path. The native branches
dropped it silently, so a working jar deployment moved to native would
lose its settings without any message -- a JAVA_OPTS-supplied
quarkus.http.port was ignored and the node bound the default port
instead.

A GraalVM native image accepts -D system properties and the -X memory
flags, so those are now forwarded verbatim. It has no HotSpot -XX:
namespace, agents or module flags -- passing those through aborts
startup with "error: Could not find option ..." -- so they are dropped
with an explicit warning naming the entries rather than silently
ignored or allowed to break the launch.

Verified against the built binary: -Xmx/-Xms/-D/-verbose accepted,
-XX:+UseG1GC and -XX:MaxRAMPercentage rejected by the image.
The first version rejected the whole -XX: namespace, but a GraalVM image
implements its own: this binary advertises 48 options including
MaxHeapSize, MinHeapSize, MaximumHeapSizePercent and VerboseGC. Dropping
them silently discarded valid native tuning -- the same class of bug
this change set out to remove -- and the warning wrongly claimed they
had no native equivalent.

-XX: is now forwarded. A HotSpot-only flag is rejected by the image
itself with an immediate, precise "error: Could not find option 'X'.
Use -XX:PrintFlags= to list all available options", which is a better
outcome than silently changing the GC the operator asked for. The
launcher no longer second-guesses an option set that varies by GraalVM
version.

Only JVM agents and module-system flags, which a native image can never
implement, are still dropped, and the warning now says exactly that.

Verified end to end: the native node starts with
-XX:MaximumHeapSizePercent=70 -XX:+VerboseGC and the GC tracing takes
effect.
@satran004

Copy link
Copy Markdown
Member Author

Good catch — verified and fixed in 6b57d88.

The claim is correct. I probed the built binary and it advertises 48 -XX: options of its own:

-XX:MaxHeapSize=0                 The maximum heap size at run-time, in bytes.
-XX:MinHeapSize=0                 The minimum heap size at run-time, in bytes.
-XX:MaximumHeapSizePercent=80     The maximum heap size as percent of physical memory.
-XX:±VerboseGC                    Print more information about the heap before and after each collection.
-XX:±PrintGCTimes                 Print the time for each of the phases of each collection, if +VerboseGC.

So the blanket drop discarded valid native tuning, and the warning text was wrong to say these have no native equivalent.

It was also self-defeating: silently dropping an option the operator asked for is exactly the bug this PR set out to remove.

Fix: -XX: is now forwarded. What made the original rationale collapse is that the failure mode I was guarding against is actually a good one — a HotSpot-only flag is rejected by the image immediately and precisely:

error: Could not find option 'UseG1GC'. Use -XX:PrintFlags= to list all available options.

That beats silently changing the GC the operator configured. The launcher no longer second-guesses an option set that varies by GraalVM version.

Still dropped, and only these: JVM agents (-javaagent, -agentlib, -agentpath) and module-system flags (--add-*, --enable-preview) — constructs a native image can never implement. The warning now says exactly that and points at -XX:PrintFlags=.

Verified end to end, not just in argv: the native node starts with -XX:MaximumHeapSizePercent=70 -XX:+VerboseGC (health 200) and the GC tracing actually appears in the log — so the option is honoured, not merely passed.

…image

Two follow-ups to the -XX: correction.

-X is the asymmetric case. A native image rejects an unknown -XX: loudly
but accepts an unknown -X without a word: -Xdefinitely-not-an-option
starts the node normally. Forwarding all of -X therefore recreated the
silent misconfiguration this change set out to remove -- a typo or a
HotSpot-only flag would take no effect and say nothing. Only
-Xmx/-Xms/-Xss are verified, so anything else under -X is still
forwarded, so a newer GraalVM keeps working, but is now called out.

docker/native/entrypoint.sh dropped JAVA_OPTS entirely while its JVM
counterpart forwarded it -- the same defect as yano.sh, in a launcher
the first commit missed. It now applies the identical rules.

Verified against the built binary: unknown -XX: rejected, unknown -X
silently accepted, and a mixed JAVA_OPTS starts the node with both
warnings emitted and the compatible entries applied.
@satran004

Copy link
Copy Markdown
Member Author

Correct again — fixed in bb52afa. And chasing it turned up a launcher the PR had missed entirely.

The -X point

Confirmed against the built binary. The two namespaces behave differently:

unknown flag native binary
-XX:bogus louderror: Could not find option 'bogus'. Use -XX:PrintFlags= ...
-Xdefinitely-not-an-option silently ignored, node starts normally

-X alone doesn't print help either, so the image cannot tell an operator which -X it honours. Blanket-forwarding -X therefore recreated exactly the silent misconfiguration this PR exists to remove: a typo or a HotSpot-only -X takes no effect and says nothing.

That asymmetry is also why the two namespaces now get different treatment, which is the underlying principle: forward where the binary reports errors loudly; warn where it fails silently.

Rather than a hard allowlist I kept forwarding unverified -X but call it out, so a newer GraalVM adding an -X keeps working instead of being silently dropped by us — swapping one silent failure for another would miss the point. Three-way classification now:

  • forwarded silently-D, -Xmx, -Xms, -Xss, -XX:, -verbose
  • forwarded with a warning — any other -X, "this image may ignore silently"
  • dropped with a warning — JVM agents and module-system flags

The launcher the PR missed

While checking whether other copies existed, docker/native/entrypoint.sh:31 had the identical original bug — it dropped JAVA_OPTS entirely while docker/jvm/entrypoint.sh:31 forwarded it. Anyone running the native container with JAVA_OPTS set lost it silently, exactly as the host launcher did. It now applies the same rules (POSIX sh, so a separate implementation, cross-referenced in comments).

Thanks for pushing on this one — the first fix was self-consistent but wrong in both directions.

Verified

Real binary, mixed JAVA_OPTS="-Xmx6g -Xss1m -XX:+VerboseGC -Xbogus-flag -javaagent:/x.jar -Dquarkus.http.port=7071":

Warning: dropping JAVA_OPTS entries a native image cannot implement: -javaagent:/x.jar
Warning: forwarding JAVA_OPTS entries this image may ignore silently: -Xbogus-flag
health=200
process args: -Xmx6g -Xss1m -XX:+VerboseGC -Xbogus-flag

bash -n and sh -n clean on both launchers.

@satran004
satran004 merged commit bfcb6a7 into main Aug 18, 2026
4 checks passed
@satran004
satran004 deleted the fix/native-java-opts-warning branch August 18, 2026 05:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant