From 5d89651bf66bf5fa4aee066518de6ffd9cec5a74 Mon Sep 17 00:00:00 2001 From: Jorge Miguel Silva Date: Tue, 4 Aug 2026 22:23:42 +0100 Subject: [PATCH 1/8] Route R operations to webR and bridge results between runtimes 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. --- src/utils/rRuntime.js | 72 ++++++++++--------- src/utils/toolUtils.js | 152 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 177 insertions(+), 47 deletions(-) diff --git a/src/utils/rRuntime.js b/src/utils/rRuntime.js index 7ac3fc2..9e09642 100644 --- a/src/utils/rRuntime.js +++ b/src/utils/rRuntime.js @@ -80,46 +80,54 @@ export default class RRuntime { if (!existing?.exists) throw err; } - for (const image of libraryImages) { - await webR.FS.mkdir(image.mountPoint); - await webR.FS.mount( - "WORKERFS", - { - packages: [ - { blob: new Blob([await inflate(image.data)]), metadata: image.metadata }, - ], - }, - image.mountPoint - ); - // R only searches libraries listed in .libPaths(), so mounting is not - // enough on its own. - await webR.evalRVoid(".libPaths(c(libpath, .libPaths()))", { - env: { libpath: image.mountPoint }, - }); - - // Mounting an image whose bytes R cannot read does not fail. The mount - // succeeds, the directory is empty, and the first symptom is - // "there is no package called 'x'" from whichever operation runs first, - // which points at the recipe rather than at the image. Check here - // instead, while there is still something useful to say. - const mounted = await webR.evalRNumber("length(list.files(dir))", { - env: { dir: image.mountPoint }, - }); - if (mounted === 0) { - throw new Error( - `R package library mounted at ${image.mountPoint} is empty; the filesystem image could not be read` - ); - } - } - const shelter = await new webR.Shelter(); const runtime = new RRuntime(webR, shelter); + for (const image of libraryImages) { + await runtime.mountLibrary(image); + } + await webR.evalRVoid("setwd(dir)", { env: { dir: WORK_DIR } }); return runtime; } + /** + * Mounts a package library and puts it on the R library search path. + * + * Separate from create() because a run can involve more than one R operation, + * each bringing its own library, and booting a second webR for the second + * operation would mean paying for the runtime twice. + */ + async mountLibrary({ mountPoint, data, metadata }) { + await this.webR.FS.mkdir(mountPoint); + await this.webR.FS.mount( + "WORKERFS", + { packages: [{ blob: new Blob([await inflate(data)]), metadata }] }, + mountPoint + ); + + // R only searches libraries listed in .libPaths(), so mounting alone is not + // enough. + await this.webR.evalRVoid(".libPaths(c(libpath, .libPaths()))", { + env: { libpath: mountPoint }, + }); + + // Mounting an image whose bytes R cannot read does not fail. The mount + // succeeds, the directory is empty, and the first symptom is + // "there is no package called 'x'" from whichever operation runs first, + // which points at the recipe rather than at the image. Check here instead, + // while there is still something useful to say. + const mounted = await this.webR.evalRNumber("length(list.files(dir))", { + env: { dir: mountPoint }, + }); + if (mounted === 0) { + throw new Error( + `R package library mounted at ${mountPoint} is empty; the filesystem image could not be read` + ); + } + } + /** Writes a file into the run's working directory. */ async mount({ name, data }) { const path = `${WORK_DIR}/${name}`; diff --git a/src/utils/toolUtils.js b/src/utils/toolUtils.js index 5c3e4f4..8238cc7 100644 --- a/src/utils/toolUtils.js +++ b/src/utils/toolUtils.js @@ -1,4 +1,5 @@ import Aioli from "./aioli-custom/aioli" +import RRuntime from "./rRuntime" import logger from "./logger"; import { detectIsBinaryFile } from "./detectDataType"; import { makeBinaryDataValue, makeTextDataValue } from './dataValue' @@ -243,6 +244,46 @@ async function getToolBlobs(toolName) { return [wasmBlob, jsBlob] } +/** An operation runs under webR when its bundle carries an `r` runtime block. */ +export function isROperation(toolName) { + return Boolean(getTool(toolName)?.runtime?.r) +} + +async function fetchToolBytes(base_url, repo, digest, authorization) { + const res = await fetch(`${base_url}/${repo}/blobs/${digest}`, { + headers: { Authorization: authorization, Accept: "application/octet-stream" }, + }) + if (!res.ok) throw new Error(`Failed to fetch ${digest} for ${repo}: ${res.status} ${res.statusText}`) + return new Uint8Array(await res.arrayBuffer()) +} + +/** + * Fetches what one R operation needs: the package library, its index, and the + * script. + * + * Unlike the wasm path these are not turned into object URLs. The library has + * to be handed to webR as bytes so it can be inflated and mounted, and the + * script has to be handed over as source. + */ +async function getRToolBlobs(toolName) { + const toolConfig = getTool(toolName) + const { authorization, base_url } = await getAuthorizationAndBaseUrl(toolConfig.repo) + const { library_digest, metadata_digest, script_digest } = toolConfig.runtime.r + + const [library, metadataBytes, scriptBytes] = await Promise.all([ + fetchToolBytes(base_url, toolConfig.repo, library_digest, authorization), + fetchToolBytes(base_url, toolConfig.repo, metadata_digest, authorization), + fetchToolBytes(base_url, toolConfig.repo, script_digest, authorization), + ]) + + const decoder = new TextDecoder("utf-8") + return { + library, + metadata: JSON.parse(decoder.decode(metadataBytes)), + script: decoder.decode(scriptBytes), + } +} + async function aioliReadFileHelper(CLI, fileName) { const stat = await CLI.ls(fileName) @@ -306,6 +347,10 @@ export async function runTools( // prepare tools to be loaded by aioli let aioliTools = [] for (const invocation of toolInvocations) { + // R operations are not aioli tools: they have no wasm binary, and webR runs + // in a worker of its own. + if (isROperation(invocation.toolName)) continue + const toolAlreadyAdded = aioliTools.some((t) => t.tool == invocation.toolName) if (toolAlreadyAdded) continue @@ -326,16 +371,45 @@ export async function runTools( const hasNonReinitTool = aioliTools.some((t) => t.reinit === false); if (!hasNonReinitTool) aioliTools.push("base/1.0.0") - // load tools - const CLI = await new Aioli(aioliTools, { - // TODO(andrade) look again into what each argument does - printInterleaved: false, - debug: false, - }); + // load tools. A run made up entirely of R operations needs no aioli worker, + // and Aioli rejects an empty tool list, so only build one when something will + // use it. + const needsAioli = toolInvocations.some((i) => !isROperation(i.toolName)) + const CLI = needsAioli + ? await new Aioli(aioliTools, { + // TODO(andrade) look again into what each argument does + printInterleaved: false, + debug: false, + }) + : null; + + // webR is started on first use rather than up front. It is a far larger + // download than any single tool's wasm, and most runs never touch R. + let R = null + const rScripts = new Map() + async function loadROperation(toolName) { + if (rScripts.has(toolName)) return rScripts.get(toolName) + + const { library, metadata, script } = await getRToolBlobs(toolName) + const image = { mountPoint: `/lib-${rScripts.size}`, data: library, metadata } + + if (!R) R = await RRuntime.create({ libraryImages: [image] }) + else await R.mountLibrary(image) + + rScripts.set(toolName, script) + return script + } + + try { // 1. Prepare the inputs for (const invocation of toolInvocations) { const toolDefinition = getTool(invocation.toolName) + const isR = isROperation(invocation.toolName) + // Each invocation runs against whichever runtime owns it. The two have + // separate filesystems, so this is also which filesystem its files live in. + const rScript = isR ? await loadROperation(invocation.toolName) : null + const runtime = isR ? R : CLI let args = invocation.toolArguments let lastArgs = [] // arguments that must appear after all flagged arguments. @@ -350,7 +424,7 @@ export async function runTools( if (inputDefinition.mode === "file") { inputFileName = `input-${invocation.uniqueId}-${inputDefinition.name}.txt` const fileContent = value.kind === "binary" ? new Blob([value.data]) : value.data; - await CLI.mount({ name: inputFileName, data: fileContent }) + await runtime.mount({ name: inputFileName, data: fileContent }) } else if (inputDefinition.mode === "stdin") { stdinValue = value.data @@ -360,7 +434,26 @@ export async function runTools( const [source, sourceOutput] = value inputFileName = `${source}-${sourceOutput}.txt` - if (inputDefinition.mode === "stdin") { + if (isR) { + // An aioli tool's output file lives in the aioli worker's + // filesystem, which webR cannot see. Every output is also kept as a + // DataValue, so the bytes are copied across from there rather than + // read back out of a filesystem this runtime has no access to. + const produced = outputs[source]?.[sourceOutput] + if (!produced) { + errors[invocation.uniqueId] ??= [] + errors[invocation.uniqueId].push( + `Input "${inputDefinition.name}" expected output "${sourceOutput}" of ${source}, which was not produced` + ) + } else { + await runtime.mount({ + name: inputFileName, + data: produced.kind === "binary" ? new Blob([produced.data]) : produced.data, + }) + if (inputDefinition.mode === "stdin") stdinValue = produced.data + } + } + else if (inputDefinition.mode === "stdin") { const fileData = await aioliReadFileHelper(CLI, inputFileName) stdinValue = fileData.data } @@ -376,7 +469,7 @@ export async function runTools( } } else if (inputDefinition.mode === "stdin") { - CLI.stdin = stdinValue + runtime.stdin = stdinValue } }; @@ -396,8 +489,12 @@ export async function runTools( args = [...args, ...lastArgs] - // 2. Run the tool - const { stdout, stderr } = await CLI.exec(invocation.toolName, args) + // 2. Run the tool. 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 produced. + const { stdout, stderr } = isR + ? await runtime.exec(rScript, args) + : await runtime.exec(invocation.toolName, args) errors[invocation.uniqueId] = [stderr] logger.log("[runMultipleTools]", invocation.toolName, { @@ -416,13 +513,13 @@ export async function runTools( let result if (outputDefinition.mode === "stdout") { - await CLI.mount({ name: outputFileName, data: stdout }) + await runtime.mount({ name: outputFileName, data: stdout }) result = makeTextDataValue(stdout) } else if (outputDefinition.mode === "file") { const fileToRead = outputDefinition.filename ?? `${invocation.uniqueId}-${outputDefinition.name}.txt` - result = await aioliReadFileHelper(CLI, fileToRead) + result = await aioliReadFileHelper(runtime, fileToRead) if (!result) { // TODO maybe have a proper way to handle this result = makeTextDataValue("") @@ -430,13 +527,23 @@ export async function runTools( } if (result.kind == "binary") { - await CLI.mount({ name: outputFileName, data: new Blob([result.data]) }) + await runtime.mount({ name: outputFileName, data: new Blob([result.data]) }) } else { - await CLI.mount({ name: outputFileName, data: result.data }) + await runtime.mount({ name: outputFileName, data: result.data }) } } + // An R operation's results are invisible to the aioli worker, so a + // subsequent C tool reading `${uniqueId}-${name}.txt` would not find + // them. Copy them across while the bytes are to hand. + if (isR && CLI) { + await CLI.mount({ + name: outputFileName, + data: result.kind === "binary" ? new Blob([result.data]) : result.data, + }) + } + outputs[invocation.uniqueId] ??= {}; outputs[invocation.uniqueId][outputDefinition.name] = result; } @@ -448,6 +555,21 @@ export async function runTools( logger.log("[runMultipleTools] Results", outputs, errors); return { "outputs": outputs, "errors": errors } + } finally { + // Each run owns its workers: an aioli worker holding the loaded tools and + // their wasm heaps, and a webR worker holding an R session. Without this + // they accumulate for the lifetime of the page, one set per run, including + // when a run throws part way. Every output is materialised as a DataValue + // before this point, so nothing reads either filesystem afterwards. + for (const [name, worker] of [["Aioli", CLI], ["webR", R]]) { + if (!worker) continue + try { + await worker.close() + } catch (err) { + logger.warn(`[runMultipleTools] Failed to close the ${name} worker`, err) + } + } + } } export function getToolInputByName(toolName, inputName) { From 4d6254866f642fb835d51b8f842edc9ec2c3ef30 Mon Sep 17 00:00:00 2001 From: Jorge Miguel Silva Date: Tue, 4 Aug 2026 22:37:56 +0100 Subject: [PATCH 2/8] Keep errors recorded while preparing an invocation's inputs 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. --- src/utils/toolUtils.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/utils/toolUtils.js b/src/utils/toolUtils.js index 8238cc7..22acfb4 100644 --- a/src/utils/toolUtils.js +++ b/src/utils/toolUtils.js @@ -495,7 +495,10 @@ export async function runTools( const { stdout, stderr } = isR ? await runtime.exec(rScript, args) : await runtime.exec(invocation.toolName, args) - errors[invocation.uniqueId] = [stderr] + // Appended rather than assigned: preparing the inputs can already have + // recorded a problem against this invocation, and assigning here would + // discard it. + errors[invocation.uniqueId] = [...(errors[invocation.uniqueId] ?? []), stderr] logger.log("[runMultipleTools]", invocation.toolName, { args, From 3e6fa6dcba171ebbf711fb72073b7fee0e1d33f0 Mon Sep 17 00:00:00 2001 From: Jorge Miguel Silva Date: Tue, 4 Aug 2026 23:06:17 +0100 Subject: [PATCH 3/8] Harden the R dispatch after review 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. --- src/utils/rRuntime.js | 14 ++++++++++++- src/utils/toolUtils.js | 45 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/utils/rRuntime.js b/src/utils/rRuntime.js index 9e09642..095abfe 100644 --- a/src/utils/rRuntime.js +++ b/src/utils/rRuntime.js @@ -68,8 +68,20 @@ export default class RRuntime { * build, each an object of { data, metadata } URLs. They carry the compiled * R packages; without them only base R is available. */ - static async create({ libraryImages = [] } = {}) { + static async create({ libraryImages = [], webrVersion = null } = {}) { const webR = new WebR({ interactive: false }); + + // The packages in a library image are only loadable by the webR release + // they were compiled against, and the recipe records which that was. A + // mismatch otherwise surfaces as a load error inside the worker with + // nothing to attribute it to, most likely after a routine dependency bump + // here rather than any change to the recipe. + if (webrVersion && webR.version !== webrVersion) { + throw new Error( + `R package library was built for webR ${webrVersion}, but this build runs webR ${webR.version}` + ); + } + await webR.init(); try { diff --git a/src/utils/toolUtils.js b/src/utils/toolUtils.js index 22acfb4..a610838 100644 --- a/src/utils/toolUtils.js +++ b/src/utils/toolUtils.js @@ -160,7 +160,14 @@ export async function loadToolIndex() { const indexJson = await fetchBlob(base_url, "biochef-plugins-index", digest, authorization, "application/vnd.oci.image.manifest.v1+json"); for (const [key, plugin] of Object.entries(indexJson)) { + // Merged over whatever is already known about the tool rather than + // replacing it. The index carries only six fields and not `runtime`, so + // overwriting would strip that from a tool loadTool had already resolved. + // This runs on every mount of the tools and workflow pages, and losing + // `runtime` makes isROperation answer "not R" for an R operation, which + // routes it to aioli and asks for a wasm binary that does not exist. const bundle = { + ...toolMap.get(plugin.name), ...plugin, repo: key }; @@ -265,21 +272,24 @@ async function fetchToolBytes(base_url, repo, digest, authorization) { * to be handed to webR as bytes so it can be inflated and mounted, and the * script has to be handed over as source. */ -async function getRToolBlobs(toolName) { +async function getRToolBlobs(toolName, { withLibrary = true } = {}) { const toolConfig = getTool(toolName) const { authorization, base_url } = await getAuthorizationAndBaseUrl(toolConfig.repo) const { library_digest, metadata_digest, script_digest } = toolConfig.runtime.r const [library, metadataBytes, scriptBytes] = await Promise.all([ - fetchToolBytes(base_url, toolConfig.repo, library_digest, authorization), - fetchToolBytes(base_url, toolConfig.repo, metadata_digest, authorization), + // Skipped when this library is already mounted. Every operation in a + // recipe ships the same library, and each lives in its own registry repo, + // so nothing below this would deduplicate the download. + withLibrary ? fetchToolBytes(base_url, toolConfig.repo, library_digest, authorization) : null, + withLibrary ? fetchToolBytes(base_url, toolConfig.repo, metadata_digest, authorization) : null, fetchToolBytes(base_url, toolConfig.repo, script_digest, authorization), ]) const decoder = new TextDecoder("utf-8") return { library, - metadata: JSON.parse(decoder.decode(metadataBytes)), + metadata: metadataBytes ? JSON.parse(decoder.decode(metadataBytes)) : null, script: decoder.decode(scriptBytes), } } @@ -387,14 +397,27 @@ export async function runTools( // download than any single tool's wasm, and most runs never touch R. let R = null const rScripts = new Map() + // Keyed by library digest, not by tool. Every operation in a recipe ships the + // same library, so a three-operation workflow would otherwise fetch and mount + // three identical copies and hold each decompressed in the worker. + const rLibraries = new Map() async function loadROperation(toolName) { if (rScripts.has(toolName)) return rScripts.get(toolName) - const { library, metadata, script } = await getRToolBlobs(toolName) - const image = { mountPoint: `/lib-${rScripts.size}`, data: library, metadata } + const { webr_version, library_digest } = getTool(toolName).runtime.r + const alreadyMounted = rLibraries.has(library_digest) + const { library, metadata, script } = await getRToolBlobs(toolName, { withLibrary: !alreadyMounted }) - if (!R) R = await RRuntime.create({ libraryImages: [image] }) - else await R.mountLibrary(image) + if (!alreadyMounted) { + const image = { + mountPoint: `/lib-${rLibraries.size}`, + data: library, + metadata, + } + if (!R) R = await RRuntime.create({ libraryImages: [image], webrVersion: webr_version }) + else await R.mountLibrary(image) + rLibraries.set(library_digest, image.mountPoint) + } rScripts.set(toolName, script) return script @@ -405,6 +428,12 @@ export async function runTools( // 1. Prepare the inputs for (const invocation of toolInvocations) { const toolDefinition = getTool(invocation.toolName) + // isROperation reads the bundle, which only exists once loadTool has run. + // Without this an unloaded tool would answer "not R" and be routed to + // aioli, failing later on a missing wasm digest rather than here. + if (!toolDefinition?.runtime) { + throw new Error(`Tool ${invocation.toolName} was not loaded before running`) + } const isR = isROperation(invocation.toolName) // Each invocation runs against whichever runtime owns it. The two have // separate filesystems, so this is also which filesystem its files live in. From 11346f7847036e89d1201058fe60e1bb985e99b8 Mon Sep 17 00:00:00 2001 From: Jorge Miguel Silva Date: Tue, 4 Aug 2026 23:45:39 +0100 Subject: [PATCH 4/8] Fix worker leaks and stream handling in the R runtime 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. --- src/components/RecipePanel.js | 26 +++++++++++-- src/utils/rRuntime.js | 73 +++++++++++++++++++++++++++-------- src/utils/toolUtils.js | 22 +++++++---- 3 files changed, 94 insertions(+), 27 deletions(-) diff --git a/src/components/RecipePanel.js b/src/components/RecipePanel.js index 32a9f1c..0f24ff0 100644 --- a/src/components/RecipePanel.js +++ b/src/components/RecipePanel.js @@ -615,9 +615,16 @@ const RecipePanel = forwardRef(({ selectedNode, setSelectedNode, indexLoaded }, } } - const { outputs, errors } = await runTools( - toolInvocations, - (nodeId, outputs, errors) => { + // isRunning is cleared per node as each invocation finishes, so a run that + // throws before reaching a node leaves it spinning with no error and no way + // back except a reload. The wasm path rarely threw -- a failed blob fetch + // returns null -- but loading an R operation can fail outright: a blob that + // will not fetch, a library image that will not read, a webR version that + // does not match the one the packages were built for. + try { + await runTools( + toolInvocations, + (nodeId, outputs, errors) => { const messages = { ...(getNode(nodeId).data.toolMessages ?? {}), "Output": {}, @@ -644,8 +651,19 @@ const RecipePanel = forwardRef(({ selectedNode, setSelectedNode, indexLoaded }, toolMessages: messages, isRunning: false }) + } + ) + } catch (err) { + logger.error("[runWorkflow] the run failed", err) + showNotification(`Workflow run failed: ${err.message}`, "error") + } finally { + // Whatever happened, no node is still running. + for (const nodeId of component) { + if (getNode(nodeId).type == "workflowNode") { + updateNodeData(nodeId, { isRunning: false }) + } } - ) + } } // TODO diff --git a/src/utils/rRuntime.js b/src/utils/rRuntime.js index 095abfe..2a11610 100644 --- a/src/utils/rRuntime.js +++ b/src/utils/rRuntime.js @@ -69,19 +69,39 @@ export default class RRuntime { * R packages; without them only base R is available. */ static async create({ libraryImages = [], webrVersion = null } = {}) { + // The worker is spawned by the constructor, not by init(), so everything + // from here on has to be able to close it again. Without that a failed + // start leaks a worker, and the natural response -- pressing Run again -- + // leaks another. const webR = new WebR({ interactive: false }); - // The packages in a library image are only loadable by the webR release - // they were compiled against, and the recipe records which that was. A - // mismatch otherwise surfaces as a load error inside the worker with - // nothing to attribute it to, most likely after a routine dependency bump - // here rather than any change to the recipe. - if (webrVersion && webR.version !== webrVersion) { - throw new Error( - `R package library was built for webR ${webrVersion}, but this build runs webR ${webR.version}` - ); + try { + // The packages in a library image are only loadable by the webR release + // they were compiled against, and the recipe records which that was. A + // mismatch otherwise surfaces as a load error inside the worker with + // nothing to attribute it to, most likely after a routine dependency + // bump here rather than any change to the recipe. + if (webrVersion && webR.version !== webrVersion) { + throw new Error( + `R package library was built for webR ${webrVersion}, but this build runs webR ${webR.version}` + ); + } + + return await RRuntime.start(webR, libraryImages); + } catch (err) { + // try/catch rather than .catch(): close() returns undefined rather than a + // promise when the worker never initialised, and calling .catch on that + // throws a TypeError that replaces the error actually being reported. + try { + await webR.close(); + } catch (closeErr) { + logger.warn("[RRuntime.create] Failed to close a webR worker after a failed start", closeErr); + } + throw err; } + } + static async start(webR, libraryImages) { await webR.init(); try { @@ -159,8 +179,13 @@ export default class RRuntime { const info = await this.webR.FS.analyzePath(`${WORK_DIR}/${name}`); if (!info?.exists) return false; - const bytes = await this.webR.FS.readFile(`${WORK_DIR}/${name}`); - return { size: bytes.length }; + // Stat rather than read: read() follows immediately afterwards, and + // reading here to report a length would copy every output across the + // worker boundary twice. + const size = await this.webR.evalRNumber("file.size(p)", { + env: { p: `${WORK_DIR}/${name}` }, + }); + return { size }; } /** Reads a file back as bytes. */ @@ -192,7 +217,15 @@ export default class RRuntime { // array carries nothing to infer from: it raises "Cannot convert undefined // or null to object" inside the worker, which would make every operation // that takes no arguments fail to run at all. - await globalEnv.bind("argv", await new this.webR.RCharacter(args.map(String))); + // Allocated from the shelter, not globally: an object created with + // new webR.RCharacter belongs to the global shelter and would survive + // purge(), leaking one vector per invocation. The empty case is built in R + // rather than converted from an empty JS array, which webR cannot infer a + // type from. + const argv = args.length + ? await this.shelter.evalR("as.character(x)", { env: { x: args.map(String) } }) + : await this.shelter.evalR("character(0)"); + await globalEnv.bind("argv", argv); await globalEnv.bind("stdin", this.stdin ?? ""); const capture = await this.shelter.captureR(RUN_OPERATION, { @@ -203,12 +236,20 @@ export default class RRuntime { captureConditions: false, }); - let stdout = ""; - let stderr = ""; + // webR reports one event per line with the terminator stripped, so the + // lines are rejoined rather than each having one appended: appending would + // add a trailing newline to output that never had one. + const out = []; + const err = []; for (const line of capture.output) { - if (line.type === "stdout") stdout += `${line.data}\n`; - else stderr += `${line.data}\n`; + (line.type === "stdout" ? out : err).push(line.data); } + const stdout = out.join("\n"); + let stderr = err.length ? `${err.join("\n")}\n` : ""; + + // Consumed, as aioli's worker does when its buffer drains. Left in place it + // would be delivered again to the next operation, which never asked for it. + this.stdin = ""; const failure = await capture.result.toString(); await this.shelter.purge(); diff --git a/src/utils/toolUtils.js b/src/utils/toolUtils.js index a610838..c15a50e 100644 --- a/src/utils/toolUtils.js +++ b/src/utils/toolUtils.js @@ -354,6 +354,16 @@ export async function runTools( let outputs = {} let errors = {} + // isROperation reads the bundle, which exists only once loadTool has run. An + // unloaded tool would answer "not R", be treated as an aioli tool, and fail + // on the next line dereferencing a wasm digest it has no bundle for. Checked + // before anything reads a runtime, so the message names the real problem. + for (const invocation of toolInvocations) { + if (!getTool(invocation.toolName)?.runtime) { + throw new Error(`Tool ${invocation.toolName} was not loaded before running`) + } + } + // prepare tools to be loaded by aioli let aioliTools = [] for (const invocation of toolInvocations) { @@ -428,12 +438,6 @@ export async function runTools( // 1. Prepare the inputs for (const invocation of toolInvocations) { const toolDefinition = getTool(invocation.toolName) - // isROperation reads the bundle, which only exists once loadTool has run. - // Without this an unloaded tool would answer "not R" and be routed to - // aioli, failing later on a missing wasm digest rather than here. - if (!toolDefinition?.runtime) { - throw new Error(`Tool ${invocation.toolName} was not loaded before running`) - } const isR = isROperation(invocation.toolName) // Each invocation runs against whichever runtime owns it. The two have // separate filesystems, so this is also which filesystem its files live in. @@ -474,6 +478,10 @@ export async function runTools( errors[invocation.uniqueId].push( `Input "${inputDefinition.name}" expected output "${sourceOutput}" of ${source}, which was not produced` ) + // Nothing was mounted, so passing the name on would only make the + // operation fail again inside R on a file that is not there, + // burying the error that actually explains it. + continue } else { await runtime.mount({ name: inputFileName, @@ -569,7 +577,7 @@ export async function runTools( // An R operation's results are invisible to the aioli worker, so a // subsequent C tool reading `${uniqueId}-${name}.txt` would not find // them. Copy them across while the bytes are to hand. - if (isR && CLI) { + if (isR && CLI && result) { await CLI.mount({ name: outputFileName, data: result.kind === "binary" ? new Blob([result.data]) : result.data, From c36ce0236a22095c3ef63e4dcc21f1172f1ad517 Mon Sep 17 00:00:00 2001 From: Jorge Miguel Silva Date: Tue, 4 Aug 2026 23:52:55 +0100 Subject: [PATCH 5/8] Serve the webR runtime from this origin instead of a public CDN 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. --- src/utils/rRuntime.js | 33 +++++++++++++++++++++++++++++++-- webpack.config.js | 22 ++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/utils/rRuntime.js b/src/utils/rRuntime.js index 2a11610..6b46464 100644 --- a/src/utils/rRuntime.js +++ b/src/utils/rRuntime.js @@ -6,6 +6,32 @@ import logger from "./logger"; // so a recipe's io definitions mean the same thing under either runtime. const WORK_DIR = "/biochef"; +/** + * Where the webR runtime is served from. + * + * Left unset, webR fetches around 20 MB of R.js, R.wasm and its support files + * from https://webr.r-wasm.org at run time. Everything else the app executes + * comes from the registry and is checked against a digest recorded in a recipe, + * so that would be the one component arriving from a third party, unpinned, and + * the one whose availability nothing here controls. The build copies those + * files alongside the app instead (see the CopyWebpackPlugin entry for + * node_modules/webr/dist) and this points at them. + * + * __webpack_public_path__ is rewritten by webpack to wherever the bundle was + * served from, which is what makes this work under the /Biochef/ prefix the + * deployed site uses as well as at the root in development. The fallback only + * applies outside a webpack bundle, such as when a script exercises this module + * directly under Node. + */ +function runtimeBaseUrl() { + const publicPath = + typeof __webpack_public_path__ === "string" && __webpack_public_path__ + ? __webpack_public_path__ + : "/"; + + return `${publicPath.endsWith("/") ? publicPath : `${publicPath}/`}webr/`; +} + // Evaluated by captureR to run one operation. It is a fixed string: the script // path and the argument vector are bound as R variables rather than // interpolated, so nothing a recipe or a user supplies is ever parsed as code. @@ -68,12 +94,15 @@ export default class RRuntime { * build, each an object of { data, metadata } URLs. They carry the compiled * R packages; without them only base R is available. */ - static async create({ libraryImages = [], webrVersion = null } = {}) { + static async create({ libraryImages = [], webrVersion = null, baseUrl = null } = {}) { // The worker is spawned by the constructor, not by init(), so everything // from here on has to be able to close it again. Without that a failed // start leaks a worker, and the natural response -- pressing Run again -- // leaks another. - const webR = new WebR({ interactive: false }); + const webR = new WebR({ + interactive: false, + baseUrl: baseUrl ?? runtimeBaseUrl(), + }); try { // The packages in a library image are only loadable by the webR release diff --git a/webpack.config.js b/webpack.config.js index 3bac77d..50766b0 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -70,6 +70,28 @@ module.exports = { ignore: ['**/index.html'], }, }, + // The webR runtime, served from this origin rather than left to load + // from webR's public CDN. Every other artifact the app executes is + // pulled from the registry and checked against a digest recorded in a + // recipe; fetching ~20 MB of R at run time from a third party would sit + // outside that entirely, and would make R operations fail whenever that + // host is unavailable. Only the files the runtime actually requests are + // copied -- not the REPL, tests or source maps that share the package. + { + from: 'node_modules/webr/dist', + to: 'webr', + globOptions: { + ignore: [ + '**/repl/**', + '**/tests/**', + '**/*.map', + '**/*.d.ts', + '**/webr.cjs', + '**/webr.mjs', + '**/webr.js', + ], + }, + }, ], }), new webpack.DefinePlugin({ From 9dbdc056cadf55f0d3c341edab95f8764114264b Mon Sep 17 00:00:00 2001 From: Jorge Miguel Silva Date: Wed, 5 Aug 2026 01:49:39 +0100 Subject: [PATCH 6/8] Correct the previous round's stream handling and guard the cleanup 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. --- src/components/RecipePanel.js | 13 ++++- src/utils/rRuntime.js | 105 ++++++++++++++++++++++------------ src/utils/toolUtils.js | 26 +++++++-- webpack.config.js | 17 ++++++ 4 files changed, 115 insertions(+), 46 deletions(-) diff --git a/src/components/RecipePanel.js b/src/components/RecipePanel.js index 0f24ff0..53edd1c 100644 --- a/src/components/RecipePanel.js +++ b/src/components/RecipePanel.js @@ -626,7 +626,9 @@ const RecipePanel = forwardRef(({ selectedNode, setSelectedNode, indexLoaded }, toolInvocations, (nodeId, outputs, errors) => { const messages = { - ...(getNode(nodeId).data.toolMessages ?? {}), + // Also guarded: the node may have been deleted while its invocation + // was running. + ...(getNode(nodeId)?.data?.toolMessages ?? {}), "Output": {}, }; @@ -657,9 +659,14 @@ const RecipePanel = forwardRef(({ selectedNode, setSelectedNode, indexLoaded }, logger.error("[runWorkflow] the run failed", err) showNotification(`Workflow run failed: ${err.message}`, "error") } finally { - // Whatever happened, no node is still running. + // Whatever happened, no node is still running. `component` is a snapshot + // taken before the run, and a node can be deleted while it is in flight, + // so getNode may return undefined here. Left unguarded the sweep throws + // from inside the finally, masking the original error and leaving the + // remaining nodes spinning -- the exact failure this block exists to + // prevent. updateNodeData on a missing id is a no-op. for (const nodeId of component) { - if (getNode(nodeId).type == "workflowNode") { + if (getNode(nodeId)?.type == "workflowNode") { updateNodeData(nodeId, { isRunning: false }) } } diff --git a/src/utils/rRuntime.js b/src/utils/rRuntime.js index 6b46464..6e09eec 100644 --- a/src/utils/rRuntime.js +++ b/src/utils/rRuntime.js @@ -19,24 +19,32 @@ const WORK_DIR = "/biochef"; * * __webpack_public_path__ is rewritten by webpack to wherever the bundle was * served from, which is what makes this work under the /Biochef/ prefix the - * deployed site uses as well as at the root in development. The fallback only - * applies outside a webpack bundle, such as when a script exercises this module - * directly under Node. + * deployed site uses as well as at the root in development. + * + * Returns null outside a webpack bundle -- a script exercising this module + * under Node -- so that webR falls back to its own default rather than to a + * guessed path that would not resolve. Every browser build goes through + * webpack, so the copied runtime is always what the app itself loads. */ function runtimeBaseUrl() { - const publicPath = - typeof __webpack_public_path__ === "string" && __webpack_public_path__ - ? __webpack_public_path__ - : "/"; + if (typeof __webpack_public_path__ !== "string" || !__webpack_public_path__) { + return null; + } - return `${publicPath.endsWith("/") ? publicPath : `${publicPath}/`}webr/`; + const publicPath = __webpack_public_path__.endsWith("/") + ? __webpack_public_path__ + : `${__webpack_public_path__}/`; + + return `${publicPath}webr/`; } // Evaluated by captureR to run one operation. It is a fixed string: the script // path and the argument vector are bound as R variables rather than // interpolated, so nothing a recipe or a user supplies is ever parsed as code. // -// It returns "" on success or the error message on failure. That indirection is +// It returns a zero-length vector on success and the error message on failure -- +// length rather than content, because stop() with no arguments gives an empty +// conditionMessage, so "" cannot also stand for success. That indirection is // necessary rather than stylistic. captureR either surfaces R errors as a // JavaScript throw and discards everything the script printed, or keeps the // streams and swallows errors entirely, depending on captureConditions. Neither @@ -49,7 +57,7 @@ const RUN_OPERATION = `local({ source(.biochef_script, echo = FALSE, local = FALSE), error = function(e) .biochef_error <<- conditionMessage(e) ) - if (is.null(.biochef_error)) "" else .biochef_error + if (is.null(.biochef_error)) character(0) else as.character(.biochef_error) })`; /** @@ -99,9 +107,11 @@ export default class RRuntime { // from here on has to be able to close it again. Without that a failed // start leaks a worker, and the natural response -- pressing Run again -- // leaks another. + const resolvedBaseUrl = baseUrl ?? runtimeBaseUrl(); const webR = new WebR({ interactive: false, - baseUrl: baseUrl ?? runtimeBaseUrl(), + // Omitted rather than passed as null, which webR would take literally. + ...(resolvedBaseUrl ? { baseUrl: resolvedBaseUrl } : {}), }); try { @@ -160,7 +170,16 @@ export default class RRuntime { * each bringing its own library, and booting a second webR for the second * operation would mean paying for the runtime twice. */ - async mountLibrary({ mountPoint, data, metadata }) { + async mountLibrary({ mountPoint, data, metadata, webrVersion = null }) { + // Checked here too, not only at create(): a run can involve two recipes, + // and the second mounts into the session the first started. Without this + // only the first library's version is ever verified. + if (webrVersion && this.webR.version !== webrVersion) { + throw new Error( + `R package library was built for webR ${webrVersion}, but this build runs webR ${this.webR.version}` + ); + } + await this.webR.FS.mkdir(mountPoint); await this.webR.FS.mount( "WORKERFS", @@ -257,37 +276,49 @@ export default class RRuntime { await globalEnv.bind("argv", argv); await globalEnv.bind("stdin", this.stdin ?? ""); - const capture = await this.shelter.captureR(RUN_OPERATION, { - withAutoprint: false, - captureStreams: true, - // See RUN_OPERATION: errors are reported through the return value so that - // the streams survive them. - captureConditions: false, - }); + let stdout = ""; + let stderr = ""; + let failure = null; - // webR reports one event per line with the terminator stripped, so the - // lines are rejoined rather than each having one appended: appending would - // add a trailing newline to output that never had one. - const out = []; - const err = []; - for (const line of capture.output) { - (line.type === "stdout" ? out : err).push(line.data); - } - const stdout = out.join("\n"); - let stderr = err.length ? `${err.join("\n")}\n` : ""; - - // Consumed, as aioli's worker does when its buffer drains. Left in place it - // would be delivered again to the next operation, which never asked for it. - this.stdin = ""; + try { + const capture = await this.shelter.captureR(RUN_OPERATION, { + withAutoprint: false, + captureStreams: true, + // See RUN_OPERATION: errors are reported through the return value so + // that the streams survive them. + captureConditions: false, + }); + + // webR reports one event per line with the terminator stripped, and does + // so identically whether or not the output ended in one: cat("x\n") and + // cat("x") both arrive as a single "x". The terminator cannot be + // recovered, so one is appended per line, which reconstructs the + // overwhelmingly common case of newline-terminated output and invents one + // only for output that deliberately omitted it. + for (const line of capture.output) { + if (line.type === "stdout") stdout += `${line.data}\n`; + else stderr += `${line.data}\n`; + } - const failure = await capture.result.toString(); - await this.shelter.purge(); + // Read before the shelter is purged, which would invalidate it. + const result = await capture.result.toJs(); + failure = result.values.length ? String(result.values[0] ?? "") : null; + } finally { + // Both belong here rather than on the success path. captureR can reject + // outright -- a wasm trap is not an R condition -- and leaving stdin set + // would deliver this operation's input to the next one, which never asked + // for it, exactly as aioli's worker clears its buffer once drained. + this.stdin = ""; + await this.shelter.purge(); + } - if (failure) { + if (failure !== null) { // Surfaced as stderr rather than thrown, so that a failing R operation // behaves like a failing command line tool: the pipeline records the // error against the invocation and keeps whatever was produced. - stderr += `${failure}\n`; + // stop() with no arguments gives an empty message, which would otherwise + // add a bare newline and say nothing. + stderr += `${failure || "the operation failed without reporting a reason"}\n`; logger.warn("[RRuntime.exec] R operation failed", failure); } diff --git a/src/utils/toolUtils.js b/src/utils/toolUtils.js index c15a50e..e3be876 100644 --- a/src/utils/toolUtils.js +++ b/src/utils/toolUtils.js @@ -200,8 +200,10 @@ export async function loadTool(toolName) { ); if (!bundleLayer) { - console.error(`No bundle.json layer found for ${repo}`); - return; + // false, not undefined: callers test `result == false`, so returning + // undefined here reads as a successful load and the tool is used anyway. + logger.error(`No bundle.json layer found for ${repo}`); + return false; } var bundle = await fetchBlob(base_url, repo, bundleLayer.digest, authorization, "application/vnd.oci.image.manifest.v1+json"); @@ -423,6 +425,7 @@ export async function runTools( mountPoint: `/lib-${rLibraries.size}`, data: library, metadata, + webrVersion: webr_version, } if (!R) R = await RRuntime.create({ libraryImages: [image], webrVersion: webr_version }) else await R.mountLibrary(image) @@ -478,10 +481,11 @@ export async function runTools( errors[invocation.uniqueId].push( `Input "${inputDefinition.name}" expected output "${sourceOutput}" of ${source}, which was not produced` ) - // Nothing was mounted, so passing the name on would only make the - // operation fail again inside R on a file that is not there, - // burying the error that actually explains it. - continue + // The name is still passed on below. Skipping it would drop a + // positional argument and shift every later one into the wrong + // slot, so the operation would read some other file rather than + // fail: a silent misread is worse than a missing-file error, and + // the message recorded here survives to explain it. } else { await runtime.mount({ name: inputFileName, @@ -491,7 +495,17 @@ export async function runTools( } } else if (inputDefinition.mode === "stdin") { + // Returns undefined when the file is not there, which happens when + // the upstream output never materialised. Dereferencing it would + // replace a recordable problem with a TypeError. const fileData = await aioliReadFileHelper(CLI, inputFileName) + if (!fileData) { + errors[invocation.uniqueId] ??= [] + errors[invocation.uniqueId].push( + `Input "${inputDefinition.name}" expected output "${sourceOutput}" of ${source}, which was not produced` + ) + continue + } stdinValue = fileData.data } } diff --git a/webpack.config.js b/webpack.config.js index 50766b0..378fe11 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,6 +4,7 @@ const webpack = require("webpack"); const dotenv = require("dotenv"); dotenv.config(); const CopyWebpackPlugin = require('copy-webpack-plugin'); +const TerserPlugin = require('terser-webpack-plugin'); module.exports = { entry: './src/index.js', @@ -77,6 +78,12 @@ module.exports = { // outside that entirely, and would make R operations fail whenever that // host is unavailable. Only the files the runtime actually requests are // copied -- not the REPL, tests or source maps that share the package. + // R and webR are GPL-licensed, and this ships their binaries. + { + from: 'node_modules/webr/LICENSE.md', + to: 'webr/LICENSE.md', + toType: 'file', + }, { from: 'node_modules/webr/dist', to: 'webr', @@ -130,6 +137,16 @@ module.exports = { open: true, }, optimization: { + minimizer: [ + // The default minimizer, with the copied webR runtime excluded. Those + // files are shipped verbatim so they can be compared against the ones in + // the package they came from; re-minifying them changes every byte and + // makes that impossible, which was half the point of serving them + // ourselves. Everything else is minified exactly as before. + new TerserPlugin({ + exclude: /^webr\//, + }), + ], splitChunks: { chunks: 'all', }, From 9b0d64e9f7a09d604b46e17a9545eb1101509345 Mon Sep 17 00:00:00 2001 From: Jorge Miguel Silva Date: Wed, 5 Aug 2026 10:00:34 +0100 Subject: [PATCH 7/8] Guard the shelter purge in exec's finally 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. --- src/utils/rRuntime.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/utils/rRuntime.js b/src/utils/rRuntime.js index 6e09eec..72d4db6 100644 --- a/src/utils/rRuntime.js +++ b/src/utils/rRuntime.js @@ -309,7 +309,16 @@ export default class RRuntime { // would deliver this operation's input to the next one, which never asked // for it, exactly as aioli's worker clears its buffer once drained. this.stdin = ""; - await this.shelter.purge(); + + // Guarded, because the case where captureR rejected is also the case + // where the worker may be in no state to purge. An exception thrown from + // a finally replaces the one being propagated, which would hide the + // failure that actually matters. + try { + await this.shelter.purge(); + } catch (purgeErr) { + logger.warn("[RRuntime.exec] Failed to purge the R shelter", purgeErr); + } } if (failure !== null) { From 089616274e7df4a1904383c75f3350be5f0e7036 Mon Sep 17 00:00:00 2001 From: Jorge Miguel Silva Date: Wed, 5 Aug 2026 11:22:07 +0100 Subject: [PATCH 8/8] Clear stdin on a missing chained input, and correct two comments 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. --- src/utils/rRuntime.js | 10 ++++++---- src/utils/toolUtils.js | 8 ++++++-- webpack.config.js | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/utils/rRuntime.js b/src/utils/rRuntime.js index 72d4db6..5376ff3 100644 --- a/src/utils/rRuntime.js +++ b/src/utils/rRuntime.js @@ -10,10 +10,12 @@ const WORK_DIR = "/biochef"; * Where the webR runtime is served from. * * Left unset, webR fetches around 20 MB of R.js, R.wasm and its support files - * from https://webr.r-wasm.org at run time. Everything else the app executes - * comes from the registry and is checked against a digest recorded in a recipe, - * so that would be the one component arriving from a third party, unpinned, and - * the one whose availability nothing here controls. The build copies those + * from https://webr.r-wasm.org at run time. Everything else the app executes is + * addressed by a digest recorded in a recipe and served from our own registry, + * so that would be the one component arriving from a third party, identified by + * nothing, and the one whose availability nothing here controls. (The digest + * addresses the blob; this code does not itself verify the bytes it receives + * against it.) The build copies those * files alongside the app instead (see the CopyWebpackPlugin entry for * node_modules/webr/dist) and this points at them. * diff --git a/src/utils/toolUtils.js b/src/utils/toolUtils.js index e3be876..f658454 100644 --- a/src/utils/toolUtils.js +++ b/src/utils/toolUtils.js @@ -504,9 +504,13 @@ export async function runTools( errors[invocation.uniqueId].push( `Input "${inputDefinition.name}" expected output "${sourceOutput}" of ${source}, which was not produced` ) - continue + // Falls through with an empty value rather than skipping. Skipping + // would leave stdin holding whatever the previous invocation set, + // feeding this operation input it never asked for. + stdinValue = "" + } else { + stdinValue = fileData.data } - stdinValue = fileData.data } } diff --git a/webpack.config.js b/webpack.config.js index 378fe11..f8b83ed 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -73,7 +73,7 @@ module.exports = { }, // The webR runtime, served from this origin rather than left to load // from webR's public CDN. Every other artifact the app executes is - // pulled from the registry and checked against a digest recorded in a + // pulled from our own registry, addressed by a digest recorded in a // recipe; fetching ~20 MB of R at run time from a third party would sit // outside that entirely, and would make R operations fail whenever that // host is unavailable. Only the files the runtime actually requests are