Skip to contents

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:

  • which Julia binary to use when several are installed;
  • which Julia version a pinned project needs;
  • how to install Julia packages without leaving the depot in an unstable state;
  • how to load .jl bridge files reliably;
  • how to manage lazy initialisation;
  • how to recover when the Julia process goes away;
  • how to keep a Julia object alive from R and release it safely.

It is small and opinionated. In each consuming package it replaces about 100 lines of brittle boilerplate with about 5.

Backend

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.

Installation

# install.packages("remotes")
remotes::install_github("epiforecasts/juliabridge")

You also need Julia installed; juliaup is recommended.

Usage in a consuming R package

# 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)
}

API

  • 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.
    • With 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.
    • With 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.

Out of scope

  • Initialising in .onLoad. Eager initialisation there interacts badly with other compiled backends (notably Stan) and can crash R while the package attaches. Use ensure_julia().
  • A Julia REPL. That is JuliaConnectoR’s job.

Status

Used in ringbpjl; migrations of forecastbaselines, EpiAwareR, and epinow2julia are in flight.

License

MIT