Skip to content

Defer JVM loading until first use across Windows, macOS and Linux - #354

Open
e-kotov wants to merge 63 commits into
s-u:masterfrom
e-kotov:feature/deferred-jvm-upstream
Open

e-kotov wants to merge 63 commits into
s-u:masterfrom
e-kotov:feature/deferred-jvm-upstream

Conversation

@e-kotov

@e-kotov e-kotov commented Oct 23, 2026

Copy link
Copy Markdown

Hi Simon,

Following up on #334, this builds on your feature/dynamic work and extends deferred JVM loading across Windows, macOS and Linux.

The goal is the same as in our original discussion: simply loading rJava’s namespace—whether through library(rJava) or inadvertently through autocomplete—should not choose the JVM. Users could configure Java afterwards, before its first use, without restarting R merely because rJava was loaded. This does not enable switching an already loaded JVM.

The implementation was developed with GPT‑6.1 Sol, reviewed over multiple rounds with Claude 5.5, and given a final independent Fable 5.1 review. It has been extensively tested on GitHub-hosted Windows, macOS and Linux runners with multiple JDKs, including targeted checks for Windows dependencies, JavaGD, hosted JVMs and Linux loader/filesystem cases. All current checks pass; the fork’s validation PR contains the tests, run links and documented limitations.

I’m not an expert in JNI or platform loader internals, so I don’t want to overstate what passing tests and AI reviews establish. I would really appreciate your assessment of the implementation and whether you would consider merging it. Hopefully this provides a useful continuation of your work and a smoother experience for rJava and rJavaEnv users.

Thank you!

s-u and others added 30 commits October 17, 2026 18:52
…dll` linking and adding build documentation.
The previous regeneration used Autoconf 2.73 and rewrote ~1300 lines.
Regenerating with Autoconf 2.72 (the generator of upstream's configure,
which it reproduces byte for byte) limits the difference from upstream
to the dynload default and the skipped link-time JNI checks.
…s paths

Windows: replace four fixed 32768-wchar stack buffers with length-queried
heap allocations. GetFullPathNameW returns the required size when the
buffer is too small, and GetModuleFileNameW returns the buffer size when it
truncates; both were previously accepted as success. A jvm.dll already
mapped is now compared by volume serial and file ID (FileIdInfo, falling
back to BY_HANDLE_FILE_INFORMATION), so 8.3 names, junctions, symbolic
links and other spellings of the same file are accepted, while different
files and unverifiable identities are still rejected. When the identity
matches, djni takes a reference to the mapped module by address instead of
loading the alias, so a second copy of jvm.dll cannot be mapped. Errors
include the path and the system message.

POSIX: compare st_dev/st_ino instead of realpath() strings, and dlopen a
mapped JVM by its own name. Pass native-encoded paths to dlopen.

Add djni_mapped()/RgetMappedJVM: reports a JVM library already mapped into
the process and its number of created VMs (RTLD_NOLOAD or an unchanged
module reference), without loading or initializing anything. Add
compile-time checks of the JNI type sizes, which configure no longer tests
because src/jni.h replaces the JDK headers. Reject empty paths.
Restore upstream behavior for an invalid explicit Java home: as rJava did
on Windows (java.home option or JAVA_HOME) and macOS (JAVA_HOME), a value
that is not an existing directory is ignored with the upstream warning and
discovery falls back to the registry (Windows), /usr/libexec/java_home
(macOS), then java on PATH. Selection still happens only at the first
.jinit()/.jloadJVM(); an existing home without a JVM library is an error
that leaves the process able to retry.

Find the JVM library in known layouts first (lib/server, jre/lib/<arch>/
server for JDK 8 on Linux, jre/lib/server for JDK 8 on macOS, bin/server
and jre/bin/server on Windows), then search recursively.

If a JVM has already been created in the process, e.g. by a Java
application embedding R through JRI, attach to it whatever Java home is
configured. That is the only usable runtime, and the behavior of rJava
linked against the JVM library. A library mapped without a created VM
still rejects a different runtime.
test_rjava_win.R was a manual script requiring an edited JAVA_HOME, and it
was shipped at the top level of the source tarball built by mkdist. The
native Windows CI scenarios in tests-integration/ supersede it.
The guard skipped upstream's R CMD check matrix (Zulu 8 and 11 on five
runner images including Intel macOS). It adds older-JDK and vendor
coverage under upstream's own build path, so keep it running.
…arch

Run a bounded matrix per platform: legacy layouts (Temurin 8/11; Zulu 8 on
arm64 macOS), current LTS (Temurin 17/21, plus the downstream rJavaEnv
packages), and other vendors (Corretto 11, Zulu 25). The build JDK is
always Temurin 17. Every scenario starts a fresh R process with PATH
reduced to system and R directories and JAVA_HOME set explicitly, so the
runner's Java setup cannot mask discovery or dependency handling.

New scenarios: bad and over-long paths; same-runtime selection of a
preloaded library through symbolic links/junctions, 8.3 names (Windows)
and alternative spellings; non-ASCII paths with spaces; the fallback order
for an invalid Java home, with the expected home registered under HKCU on
Windows; later native loads (zip, net, management, security, mscapi, AWT
imaging) with loaded Windows modules logged; JRI started from R; and a
Java host embedding R via JRI, where rJava must attach to the host JVM
although JAVA_HOME names the other JDK.

Compare src/jni.h with each JDK's jni.h (type sizes, invocation struct
fields and every function table offset). On Windows, a native harness
checks that the bin directory added for jvm.dll's dependencies is no
longer searched after failed and successful loads, with a positive control.
djni defines wrappers named JNI_CreateJavaVM, JNI_GetCreatedJavaVMs and
JNI_GetDefaultJavaVMInitArgs. On Linux, when a JVM library is already
loaded with global symbols (the java launcher does this in a Java
application embedding R through JRI), ELF interposition bound rJava's own
calls to libjvm's exports instead: rJava saw a running VM, skipped
selection, and .jinit() then failed with "No Java run-time is loaded".
Give the wrappers hidden visibility so references bind within rJava.so.
macOS two-level namespaces and Windows imports were not affected.
Pre-existing since JRI moved to src/jri in 1.0-15 (e28ebb6): configure.win
still tested jri/configure.win, so Windows source installs silently built
no JRI. Use the src/jri paths. Needed to test JRI embedding on Windows;
independent of deferred loading.
Reproduces, inside an R process, the symbol interposition a Java host
causes on Linux.
Correcting configure.win only exposes a second pre-existing failure:
src/jri/Rdecl.h includes <Rinterface.h>, which R does not ship on Windows.
Windows source installs keep building without JRI, as on upstream master.

This reverts commit 0eacc04.
…+ runtime

A helper compiled with R CMD SHLIB creates a JVM in the R process before
rJava is used (with global symbols on POSIX), so attaching to a created
JVM is tested on Windows too, where JRI is not built from source.

On Windows, hide System32's VC++ runtime DLLs: jvm.dll and its startup
libraries must load from the JDK alone. Record whether later loads that
import another runtime DLL still work in that environment.
Rewrite tests-integration/README.md: selection order and file-identity
rule; why selected libraries are never unloaded (a separate safety
boundary); scoped Windows DLL search and the CI evidence that later loads
of awt.dll/sunmscapi.dll need a VC++ runtime outside the JDK, as upstream,
with two unimplemented options for maintainers; the test matrix; and what
is not covered, including Windows JRI (two pre-existing build defects).

Also run the later-load scenario with JDK B, so all six runtimes of the
matrix are exercised.
A JVM library mapped under another basename (renamed copy, hard link)
was invisible to djni_mapped() and the conflict check, so djni_load()
mapped a second runtime. djni now lists every loaded image (dyld,
dl_iterate_phdr, EnumProcessModules) without loading anything, and
treats an image as a JVM if it has a JVM library name or itself defines
JNI_CreateJavaVM, JNI_GetCreatedJavaVMs and JVM_CurrentTimeMillis.
Inherited definitions, forwarded Windows exports, rJava's own wrappers
and the macOS libjli (JNI shims only) do not count. Several mapped JVM
images reject selection. On Windows a JVM mapped under another module
name is rejected, as the JDK's DLLs import jvm.dll by name. If images
cannot be listed, selection fails and RgetMappedJVM signals an error.

Tests: renamed copy/hardlink/symlink, hosted renamed and a jli decoy;
CI checks each runtime's exports. Pins rJavaEnv at 693b87c.
…t name

The file at the name the loader recorded may have been deleted or replaced
since the library was mapped, e.g. by a JDK upgrade. Comparing the
requested file with it accepted B for a mapped A and reused A's handle.
Identify the mapped file from the mapping itself: /proc/self/maps on Linux,
the region vnode from proc_pidinfo on macOS, GetMappedFileNameW on Windows.
Selecting the reported name of a library with a created VM still attaches
to it. Add deleted, replaced and hosted replaced scenarios with two JDKs.
… path

.jloadJVM(path) accepted the name the loader recorded for a JVM image that
had created a VM without comparing files, so a public call with that name
succeeded after another runtime replaced the file there. Explicit paths are
now always compared with the mapped file. .jinit() attaches through a new
internal RattachJVM, which takes no path: it requires exactly one mapped JVM
image with a created VM and selects it through the reference djni holds.
…name

On musl, the libjvm.so dependency of libjava, libjimage and the other JDK
libraries is resolved through LD_LIBRARY_PATH as set at process start, not
matched to the loaded library's SONAME as glibc does. Selecting a runtime
other than the one on that path mapped a second JVM library while starting
the VM. Check by name with RTLD_NOLOAD, before creating a VM.
…artup path

Run replaced deleted and replaced replaced with A's and with B's server
directory on the startup library path, and hosted replaced with A's.
After each refusal, check that nothing from B's home was mapped.
musl takes a handle to a mapped image only by opening its recorded name
again, so a JVM library mapped under a name no JVM uses was dropped from
discovery once that file was deleted or replaced, and selecting another
runtime then mapped it next to the first (reproduced on Alpine: with B's
server directory on the startup path, .jloadJVM(B) succeeded).

Read JVM_CurrentTimeMillis, JNI_CreateJavaVM and JNI_GetCreatedJavaVMs
from the image's dynamic symbol table in memory instead, bounded by its
readable segments; an unreadable table fails discovery. Take references
only through names that resolve to the image itself, checked with
dlinfo(RTLD_DI_LINKMAP): the recorded name, libjvm.so, or for a selection
the path naming the mapped file; refuse selection or attachment without
one. Also drop trailing whitespace in djni.c and djni.h.
Add a NEWS entry for deferred selection, process-lifetime selection, host
attachment and the changed behavior. Qualify the retry wording in
?.jinit for musl, and state in the integration README what discovery and
references now guarantee on musl, with the reproduction that led to it.
glibc holds its loader lock only until dl_iterate_phdr() returns, and
dlclose() unmaps under that lock, so another thread may unmap any image,
Java or not, right after. find_jvms() read the dynamic symbol tables of
every listed image after the call returned, through saved addresses and
segment ranges, and could fault on a library unmapped meanwhile.

Identify JVM images inside the callback and keep only those, with their
dynamic section and first segment as addresses that are compared, not
read. JNI_GetCreatedJavaVMs is called only while a reference holds the
image mapped, except on musl, which never unmaps an image. Attaching now
requires the reference before calling into the image.

Add a Linux race scenario: a preloaded fixture interposes
dl_iterate_phdr() to unmap an unrelated library right after rJava lists
the images, and maps and unmaps it on another thread while discovery
runs. Compile the fixture with -Werror and show -Wall -Wextra warnings of
djni.c.
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.

2 participants