Skip to content

Route R operations to webR and bridge results between runtimes - #95

Open
jorgeMFS wants to merge 8 commits into
add-r-runtimefrom
dispatch-r-operations
Open

jorgeMFS wants to merge 8 commits into
add-r-runtimefrom
dispatch-r-operations

Conversation

@jorgeMFS

@jorgeMFS jorgeMFS commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #94, which adds the RRuntime this dispatches to. Base it on add-r-runtime, not main.

What it does

Dispatches each invocation to whichever runtime owns it, decided by whether its bundle carries an r block. An R operation is a script rather than a binary, so it is the script source that is executed — with the same argument vector the recipe's io and parameters already produce, so nothing about how a recipe describes an operation changes.

The bridging, which is the substance of this change

The two runtimes cannot share a filesystem. aioli's tools live in one worker, webR's R session in another. An R operation cannot see a file an aioli tool wrote, and a C tool cannot see what R produced. That is not a limitation worth fighting: webR owns its worker.

Every output is already kept as a DataValue, so chaining copies bytes across rather than reading a filesystem the other runtime has no access to:

  • a C tool's output is materialised into webR before an R operation consumes it
  • an R operation's results are copied back into aioli afterwards, under the same ${uniqueId}-${name}.txt name a later C tool expects

The result is that mixed pipelines work in both directions without either runtime knowing the other exists.

Two smaller things

  • A run made entirely of R operations no longer builds an aioli worker. It previously would have, purely to satisfy Aioli's refusal of an empty tool list.
  • webR starts on first use, not up front. It is a far larger download than any single tool's wasm and most runs never touch R. A second R operation mounts its library into the running session rather than starting another.

Both workers are released in a finally, so neither accumulates for the lifetime of the page when a run throws part way.

Verification

Eleven checks against stubbed runtimes, exercising the real runTools:

  • an all-R run creates no aioli worker, and closes only the webR one
  • C → R chaining materialises the C output into webR's filesystem
  • R → C chaining copies the R output back into aioli's
  • a second R tool reuses the session rather than starting another
  • both workers close on the success path and when a tool throws

Supply chain: the R runtime is served from this origin

webR loads ~20 MB of R.js/R.wasm at run time, and by default from https://webr.r-wasm.org. That would make the R interpreter the one component the app executes that comes from a third party — outside the registry, unpinned by any digest, absent from any SBOM, and available only for as long as that host is. Every other tool's wasm is pulled from our registry and checked against a digest recorded in its recipe.

Those files ship in the npm package, so the build copies them alongside the app and baseUrl points at the copy. baseUrl is derived from webpack's public path, so it resolves under the /Biochef/ prefix as well as at the root in development.

Cost: the deployed site grows by ~46 MB. A run does not download that — most of it is lazily mounted images (help, docs, translations, geospatial data) that batch execution never requests. Starting R fetches roughly 20 MB, once per page. If that size is unacceptable on Pages, the alternative is reverting to the CDN and accepting the dependency explicitly.

Verified by building and booting R 4.6.0 from the build output rather than node_modules or the CDN: starts in 2.4 s, takes its argument vector, writes its output file.

Review notes


Merge order

Stacked on #94base it on add-r-runtime, not main.

src/utils/toolUtils.js is where five open branches meet: #91, #92, #93, this one, and WildBunnie's aioli-vendored (#90). They all conflict there pairwise, so the order matters more than usual.

Suggested order: aioli-vendored (#90) first, since it is not mine and it vendors the aioli module this depends on; then #91#92; then #93; then #94 → this. Rebasing this last is deliberate — it is the largest of them and the cheapest to move.

Note #93 becomes redundant once this lands: the finally here closes both the aioli and the webR worker. If #93 lands first, which it should, drop the duplicate block when rebasing this.

Dispatches each invocation to whichever runtime owns it, decided by
whether its bundle carries an `r` block. An R operation is a script
rather than a binary, so it is the script source that is executed, with
the same argument vector the recipe's io and parameters already produce.

The two runtimes cannot share a filesystem. aioli's tools live in one
worker and webR's R session in another, so an R operation cannot see a
file an aioli tool wrote and vice versa. Every output is already kept as
a DataValue, so chaining copies the bytes across rather than reading a
filesystem the other runtime has no access to: a C tool's output is
materialised into webR before an R operation consumes it, and an R
operation's results are copied back into aioli afterwards, where a later
C tool expects to find them under the same name.

A run made entirely of R operations no longer builds an aioli worker,
which it would otherwise do only to satisfy Aioli's refusal of an empty
tool list. webR is started on first use rather than up front, since it
is a far larger download than any single tool's wasm and most runs never
touch R. A second R operation mounts its library into the running
session instead of starting another one.

Both workers are released in a finally, so neither accumulates for the
lifetime of the page when a run throws part way.

Verified with stubbed runtimes: an all-R run creates no aioli worker,
chaining copies bytes in both directions, a second R tool reuses the
session, and both workers close on the success and the error path.
Assigning the invocation's error list after exec discarded anything
already recorded against it. Preparing the inputs for an R operation can
record one: when a chained input names an output that was never
produced, there is no file to copy across and the operation runs without
it. That message was overwritten before anyone could see it, leaving a
run that failed for a knowable reason looking like it failed for none.
Three defects found by review, all of them silent.

loadToolIndex replaced whatever was already known about a tool with the
index entry, and the index carries six fields, not `runtime`. It runs on
every mount of the tools and workflow pages, so returning to a page
stripped `runtime` from a tool loadTool had already resolved.
isROperation then answered "not R" for an R operation and routed it to
aioli, which asked for a wasm binary that does not exist. The index is
now merged over what is known rather than replacing it, and runTools
refuses outright to run a tool whose bundle was never loaded instead of
guessing at its runtime.

Every operation in a recipe ships the same package library, and each
operation lives in its own registry repository, so nothing deduplicated
it: a three-operation workflow fetched the library three times and held
three decompressed copies in the worker. It is now keyed by library
digest, fetched once and mounted once.

A library is only loadable by the webR release it was built against, and
the recipe records which. The runtime now refuses a mismatch with a
message naming both versions, rather than letting a routine dependency
bump surface as an unattributable load error inside a worker.

Verified: fifteen checks, including that three operations of one recipe
share a single fetch, mount and session.
A second review pass found that two of the previous round's fixes were
themselves wrong, and turned up several defects around them.

The webR worker is spawned by the constructor, not by init(), so
anything that threw between the two leaked one. The version check added
last time sat exactly there, making a mismatch leak a worker
deterministically and the natural response -- pressing Run again -- leak
another. Startup is now wrapped so the worker is closed on any failure.
The cleanup uses try/catch rather than .catch(), because close() returns
undefined rather than a promise when the worker never initialised, and
calling .catch on that throws a TypeError that replaces the error being
reported.

The guard against running an unloaded tool was placed after the loop it
was supposed to protect. An unloaded tool was treated as an aioli tool
and died dereferencing a wasm digest it had no bundle for, several steps
before the guard could speak. It now runs before anything reads a
runtime.

Also fixed in the runtime: stdin was bound on every invocation but only
ever set, so one operation's input was delivered again to the next;
webR reports output a line at a time with the terminator stripped and
the lines were rejoined by appending one to each, giving every stdout a
newline it never had; ls() read the whole file to report its length,
copying every output across the worker boundary twice; and the argument
vector was allocated outside the shelter, so purge() could not reclaim
it.

Two in the dispatch: an output declared with mode "files" never assigns
a result, and the copy-across block dereferenced it; and an input whose
upstream output was missing recorded the problem and then passed the
filename on anyway, so the operation failed again inside R on a file
that was not there, burying the error that explained it.

Finally, runWorkflow awaited runTools with no handler, and isRunning is
cleared per node as each invocation finishes. Loading an R operation can
fail outright where the wasm path merely returned null, so a failure
left every node spinning with no error and no way back but a reload.

Verified: ten runtime checks including the version mismatch and the
stdin, newline and empty-argv cases, and fifteen dispatch checks.
webR loads around 20 MB of R.js, R.wasm and its support files at run
time, and by default takes them from https://webr.r-wasm.org. That made
the R interpreter the one component the app executes that arrives from a
third party: outside the registry, unpinned by any digest, absent from
any SBOM the recipes pipeline produces, and available only for as long
as that host is. Every wasm binary for every other tool is pulled from
our own registry and checked against a digest recorded in its recipe.

Those files ship inside the npm package already, so the build copies
them alongside the app and the runtime is pointed at the copy. Only what
webR actually requests is copied; the REPL, the tests and the source
maps that share the package are left out. baseUrl is derived from
webpack's public path, so it resolves under the /Biochef/ prefix the
deployed site uses as well as at the root in development, and remains
overridable for tests.

The deployed site grows by about 46 MB, but a run does not download
that: most of it is lazily mounted images -- help, docs, translations,
the geospatial data -- that batch execution never asks for. Starting R
fetches roughly 20 MB, once per page.

Verified by building and booting R 4.6.0 from the build output rather
than from node_modules or the CDN: it starts in 2.4 seconds, takes its
argument vector and writes its output file.
The newline change in the last commit was wrong. It assumed webR
distinguishes output that ended in a newline from output that did not.
It does not: cat("x\n") and cat("x") both arrive as a single event
carrying "x", so the terminator cannot be recovered either way. Joining
the lines therefore dropped the terminator from every newline-terminated
output, which is the overwhelmingly common case, to avoid inventing one
in the rare case. Appending per line is restored, with the reasoning
recorded so it is not "fixed" again.

The error protocol overloaded "" as "no error", but stop() with no
arguments gives an empty conditionMessage, so a failed operation with
truncated output was reported as a success. Success is now a zero-length
vector, distinguished by length rather than content.

The stdin clear and the shelter purge sat on the happy path, so a
captureR rejection -- a wasm trap is not an R condition -- skipped both.
They are in a finally now, with the result read before the purge that
would invalidate it.

The finally added to runWorkflow could itself throw. `component` is a
snapshot taken before the run, a node can be deleted while it is in
flight, and getNode then returns undefined: the sweep threw from inside
the finally, masking the original error and leaving the rest of the
nodes spinning, which is exactly what the block was added to prevent.
Both that and the callback's node lookup are guarded.

Two more from review: a second R library mounted into a running session
was never version-checked, so only the first recipe's build was
verified; and the aioli stdin path dereferenced a read that returns
undefined when the upstream output never materialised.

The skip added for a missing chained input is reverted. It avoided
compounding one error with another, but for a positional input it
dropped an argument and shifted every later one, so the operation read
the wrong file instead of failing -- a silent misread is worse, and the
recorded message survives to explain the failure either way.

loadTool returned undefined where callers test for false, so a tool with
no bundle read as loaded.

Finally, the copied webR runtime was being re-minified by Terser, so it
could not be compared against the package it came from -- half the point
of serving it ourselves. It is excluded from minification and now
matches byte for byte, and the licence covering those binaries ships
with them.

Verified: R.js, webr-worker.js, libRblas.so, libRlapack.so and R.wasm
all byte-identical to the package; R 4.6.0 boots from the build output;
eleven runtime and recipe checks; fifteen dispatch checks.
The purge runs in a finally reached when captureR rejects, and a
rejection there is exactly the case where the worker may be in no state
to purge. An exception thrown from a finally replaces the one being
propagated, so a wasm trap would have surfaced as a purge failure
instead of as itself. This is the same shape as the close().catch()
fault fixed earlier in the same file.

With this, every finally on the R path is safe against its own cleanup
failing: the shelter purge here, each worker close in runTools, and the
node sweep in runWorkflow.
The skip added for an aioli stdin input whose upstream output was
missing also skipped the assignment below it, leaving stdin holding
whatever the previous invocation had set and feeding this operation
input it never asked for. It falls through with an empty value instead.

Two comments claimed the app checks artifacts against a digest recorded
in a recipe. It does not: the digest addresses the blob in the registry,
and nothing here hashes what it receives. Since those comments are the
stated justification for shipping the webR runtime ourselves, they say
what is true now, and note explicitly that the bytes are not verified.
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