Julia setup for R packages that wrap a Julia engine.
juliabridge collects the patterns you otherwise learn the hard way when building an R package that calls Julia:
.jl bridge files reliably;It is small and opinionated. In each consuming package it replaces about 100 lines of brittle boilerplate with about 5.
juliabridge runs Julia through JuliaConnectoR, in a separate process from R. The main alternative, JuliaCall, embeds Julia in the R process. Both work for simple cases. With JuliaCall, though, R and Julia share memory, threads, signal handlers and the dynamic linker. A problem in any of them crashes both. We have seen segfaults from LD_LIBRARY_PATH poisoning of subprocess Julia, from Pkg.activate(<path>) followed by Pkg.instantiate() in in-process JuliaCall, and from accessing fields of NamedTuple results via julia_eval. With JuliaConnectoR, a Julia segfault closes a TCP socket while R keeps running. A crashed simulation can then be recovered. Out-of-process Julia also leaves a much shorter list of things you must avoid.
JuliaCall has a larger ecosystem and more frequent commits, but most of that activity is platform-compatibility work. Its in-process linking problems cannot be fixed without changing its architecture.
Consumer packages call juliabridge::eval_julia(), call_julia() and import_julia() and never touch JuliaConnectoR directly. The choice of backend stays in one place.
# install.packages("remotes")
# remotes::install_github("epiforecasts/juliabridge")
install.packages("juliabridge", repos = c('https://epiforecasts.r-universe.dev', 'https://cloud.r-project.org'))
You also need Julia installed; juliaup is recommended.
# In your package's R/zzz.R (or similar):
.mypkg_env <- new.env(parent = emptyenv())
#' @export
setup_mypkg <- function(install = TRUE) {
juliabridge::julia_ready(
packages = c("EpiBranch", "Distributions", "Random"),
github = c(EpiBranch = "epiforecasts/EpiBranch.jl"),
state_env = .mypkg_env,
install = install
)
juliabridge::julia_load_bridge(
package = "mypkg",
files = c("dist_lookup.jl", "simulate.jl")
)
}
.ensure_julia <- function() {
juliabridge::ensure_julia(.mypkg_env, setup_mypkg)
}
Then in any function that touches Julia:
my_function <- function(x) {
.ensure_julia()
juliabridge::call_julia("MyJuliaPkg.do_something", x)
}
julia_bin() resolves the Julia binary, checking JULIACONNECTOR_JULIABIN, then JULIA_BINDIR, then PATH.julia_ready(packages, github, state_env, install, project, match_manifest, verbose) installs the required Julia packages in a subprocess, then starts the JuliaConnectoR server and loads them with using. Once setup has completed, later calls return immediately.
project = "<path>", it activates and instantiates a pinned Julia project (e.g. inst/julia/Project.toml) and sets JULIA_PROJECT for the R session. The user's default Julia environment is not modified, although packages are still downloaded into the shared Julia depot. We recommend this for reproducible installs.match_manifest = TRUE (the default), it also uses the Julia version the project's Manifest.toml was resolved with, installing it via juliaup where available. A manifest pins standard libraries that exist only on that version. A binary the user chose through JULIACONNECTOR_JULIABIN or JULIA_BINDIR takes precedence. A JuliaConnectoR server that is already running, for instance one another package started, keeps its Julia.julia_load_bridge(package, files, verbose) loads .jl files from inst/julia/ of the calling package via juliaEval.ensure_julia(state_env, init_fn) is a lazy-initialisation guard. Call it at the top of any function that uses Julia.julia_alive(state_env, probe) checks that the Julia session still holds what you need. If it does not, it clears the setup flag. The next julia_ready() call then sets Julia up again.eval_julia(code), call_julia(name, ...) and import_julia(module) wrap juliaEval, juliaCall and juliaImport. get_julia(), assign_julia() and command_julia() cover the remaining common operations.manifest_julia_version(project) reads the Julia version a Manifest.toml was resolved with. juliaup_julia(version) installs a Julia version with juliaup and returns its binary, leaving the user's default channel alone.julia_handle(handle, session, state_env) holds a Julia object from R by an integer handle, paired with a token for the Julia session that created it. Both sides compare the token before acting on a handle. A handle from an old session then fails that check. The help page lists the three definitions the Julia side needs.julia_release_pending(state_env, release) releases the Julia objects of handles R has garbage-collected. Because finalisers can run partway through another Julia call, they only queue a release. Call this immediately before a Julia call of your own.julia_handle_owned(x, state_env) reports whether a handle's Julia object still belongs to this session. It returns FALSE for a handle loaded from a saved R object. It also returns FALSE for one created before julia_ready() last set Julia up.julia_spec(.fn, ...) records a Julia constructor call as an R object, rendered to Julia source only when it runs, so a call can be built, printed and checked without starting Julia. Unnamed arguments become positional and named ones keyword arguments, R types map across (2L stays an integer, 2 becomes 2.0), and a NULL keyword argument is dropped so the Julia default applies.julia(code) inserts Julia source verbatim, for what R cannot express such as a function. as_julia(x) returns the rendered code; ascii = TRUE escapes keyword names outside ASCII so they survive transfer on any platform.assert_role(x, roles) checks that a spec is what a slot expects, in whatever vocabulary the calling package uses, so a composition mistake is reported in R with the argument named rather than arriving from inside a Julia constructor..onLoad. Eager initialisation there interacts badly with other compiled backends (notably Stan) and can crash R while the package attaches. Use ensure_julia().Used in ringbpjl; migrations of forecastbaselines, EpiAwareR, and epinow2julia are in flight.
MIT