From 737666cea74e4b5310eb583f8cb9e5d470e12d3c Mon Sep 17 00:00:00 2001 From: salman Date: Fri, 18 Sep 2026 23:06:01 +0200 Subject: [PATCH] Make cgi non-blocking and borrow more ideas from muSRFlow --- README.md | 19 ++++++- TrimSPelec.js | 22 +++++++ TrimSPlib.js | 129 ++++++++++++++++++++++++++---------------- TrimSPweb.js | 53 ++++++++++------- main.js | 4 +- quick-start.html | 21 ++++++- scripts/deploy-web.sh | 95 ++++++++++++++++++++++++++----- 7 files changed, 257 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 7849bfb..1e21520 100644 --- a/README.md +++ b/README.md @@ -247,18 +247,33 @@ The script expects the TRIM.SP-NL engine to be installed already and installs: /var/www/cgi-bin/singleTrimSP.cgi CGI runner ``` +If the Plotly browser bundle is not available locally (under `node_modules`), +the script downloads it from a CDN using `curl`. Override the download URL with +`TRIMSP_PLOTLY_CDN` if you need a different mirror or version. + By default, the CGI uses the existing executable at `/usr/local/bin/trimspNL`. Set `TRIMSP_BIN_PATH` if it is installed elsewhere. +When `node` is available, the installed quick-start help page is stamped with +the release version from `package.json`; without it, the page renders +`development build` in its About section. + Deployment paths and ownership can be changed through environment variables: | Variable | Default | Purpose | | --- | --- | --- | | `TRIMSP_WEB_TARGET` | `/var/www/html/TRIMSP-NL` | Browser asset directory | | `TRIMSP_CGI_TARGET` | `/var/www/cgi-bin/singleTrimSP.cgi` | Installed CGI path | -| `TRIMSP_WEB_OWNER` | `apache` | Web file owner | -| `TRIMSP_WEB_GROUP` | `apache` | Web file group | +| `TRIMSP_WEB_OWNER` | *auto-detected* | Web file owner | +| `TRIMSP_WEB_GROUP` | *auto-detected* | Web file group | | `TRIMSP_BIN_PATH` | `/usr/local/bin/trimspNL` | Existing engine executable used by the CGI | +| `TRIMSP_PLOTLY_CDN` | `https://cdn.jsdelivr.net/npm/plotly.js-dist@1.58.5/plotly.js` | Plotly bundle download URL when unavailable locally | + +Ownership is applied automatically. When the deployed tree is already owned by +the invoking user (the common developer-machine case) the files are left as +they are; otherwise they are handed to a web-server account, preferring +`apache`, `www-data`, then `httpd`. Set `TRIMSP_WEB_OWNER` and +`TRIMSP_WEB_GROUP` to force a specific owner. For example, to use an engine installed in `/opt/trimsp/bin`: diff --git a/TrimSPelec.js b/TrimSPelec.js index a6dc358..9a810dd 100644 --- a/TrimSPelec.js +++ b/TrimSPelec.js @@ -129,6 +129,28 @@ function readAsciiFile(filename) { } return(content); } + +// Async counterparts used by the shared scan pipeline. Local disk IO is fast, +// but returning promises keeps the call sites identical across web and Electron. +async function writeAsciiFileAsync(filename,content) { + try { + await fs.promises.writeFile(filename, content, 'utf-8'); + } catch(e) { + alert('Failed to save '+filename); + return(0); + } + return(1); +} + +async function readAsciiFileAsync(filename) { + if (Array.isArray(filename)) {filename=filename[0];} + try { + return await fs.promises.readFile(filename, 'utf8'); + } catch(e) { + alert('Failed to read '+filename); + return(0); + } +} function fileExists(filename) { if (typeof filename !== 'string' || filename.length === 0) { diff --git a/TrimSPlib.js b/TrimSPlib.js index e92052c..5708e09 100644 --- a/TrimSPlib.js +++ b/TrimSPlib.js @@ -3376,18 +3376,20 @@ function adjust_scans() var scanRange = document.getElementById("ScansTable"); var scanList = document.getElementById("ScansLine"); - // Default to half the available local worker slots, rounded down, while - // keeping at least one process. Web mode keeps the default at 1 because - // the CGI backend still executes scan points sequentially. + // Default to half the available worker slots, rounded down, while keeping + // at least one process. Web mode runs scan points in parallel through + // concurrent CGI requests, bounded by TRIMSP_WEB_MAX_CONCURRENCY. if (scanThreads) { let availableThreads = 1; - if (!amIWeb() && typeof getParallelJobCount === 'function') { + if (amIWeb()) { + availableThreads = Math.max(1, TRIMSP_WEB_MAX_CONCURRENCY); + } else if (typeof getParallelJobCount === 'function') { availableThreads = Math.max(1, getParallelJobCount()); } scanThreads.max = availableThreads; - scanThreads.disabled = amIWeb(); + scanThreads.disabled = false; scanThreads.title = amIWeb() - ? 'The web backend currently runs scan points sequentially.' + ? `The web backend runs up to ${availableThreads} scan points in parallel.` : `This computer provides up to ${availableThreads} worker slots.`; if (scanThreads.value === "" || Number(scanThreads.value) < 1) { @@ -3735,6 +3737,12 @@ function yieldToRenderer() { return new Promise((resolve) => setTimeout(resolve, 0)); } +// Upper bound on concurrent scan points in the browser backend. Each job is a +// separate CGI request that runs its own TRIM.SP-NL process, so this keeps a +// large scan from overwhelming the web server while still running several +// points in parallel. +var TRIMSP_WEB_MAX_CONCURRENCY = 4; + // Format the live profile legend from the scan parameter currently being run. function formatScanLegend(scanName, scanValue) { if (scanName === "E") { @@ -3842,7 +3850,7 @@ async function startSequence() { updateStatusBar({ backend: 'Web backend' }); } updateStatusBar({ workPath: All["workPath"], run: 'Starting run...' }); - if (!writeAsciiFile(All["workPath"] + "/TrimSP.cfg", prep_cfg(0))) { + if (!await writeAsciiFileAsync(All["workPath"] + "/TrimSP.cfg", prep_cfg(0))) { return(0); } @@ -3929,7 +3937,7 @@ async function startSequence() { // Read back the outputs of one completed run and convert them into the // normalized data structures used by the live plots. async function collectRunResult(job) { - let [cols,data]= readDatFile(All["workPath"]+"/"+job.filename+".rge"); + let [cols,data]= await readDatFileAsync(All["workPath"]+"/"+job.filename+".rge"); let depth=data[0]; let nmuons=data[1]; let dz = (depth[1]-depth[0])/10; @@ -3939,7 +3947,7 @@ async function startSequence() { nmuons[i]=nmuons[i]/norm; } let seqRunFile = All["workPath"]+"/"+job.filename+".seq"; - let seqRunLines = readAsciiFile(seqRunFile).trim().split('\n'); + let seqRunLines = (await readAsciiFileAsync(seqRunFile)).trim().split('\n'); return { index: job.index, scanValue: job.scanValue, @@ -3961,13 +3969,17 @@ async function startSequence() { await yieldToRenderer(); } - // Web mode stays sequential because the CGI path still submits one job at - // a time. Local/Electron mode can use a bounded worker pool. + // Web jobs are executed by the CGI as a side effect of writing the *.inp + // file, so they only need the upload. Local jobs also need an explicit + // engine launch, which the pool performs with executeFileAsync. let rngType = initialAll["rngType"] || "pcg32"; + const canRunPool = jobs.length > 1 && (webOrApp || typeof executeFileAsync === 'function'); - if (webOrApp || jobs.length <= 1 || typeof executeFileAsync !== 'function') { + if (!canRunPool) { + // Sequential path for a single job, or the local fallback when no async + // executor exists. Local single runs keep inherited stdio for the log. for (const job of jobs) { - if (!writeAsciiFile(All["workPath"]+"/"+job.filename+".inp", job.inputFileContent)) { + if (!await writeAsciiFileAsync(All["workPath"]+"/"+job.filename+".inp", job.inputFileContent)) { return(0); } if (!webOrApp) { @@ -3980,9 +3992,12 @@ async function startSequence() { await updateCompletedResults(); } } else { - // Keep a small pool of local TRIM.SP-NL processes running concurrently. Jobs - // are inserted into the final result set by scan index as they complete. - let availableThreads = Math.max(1, getParallelJobCount()); + // Bounded worker pool. Web jobs run server-side through the CGI; local jobs + // use executeFileAsync. Jobs are inserted into the final result set by scan + // index as they complete, so out-of-order finishes do not affect the plots. + let availableThreads = webOrApp + ? TRIMSP_WEB_MAX_CONCURRENCY + : Math.max(1, getParallelJobCount()); let requestedThreads = parseInt(initialAll["scanThreads"], 10); if (Number.isNaN(requestedThreads) || requestedThreads < 1) { requestedThreads = Math.max(1, Math.floor(availableThreads / 2)); @@ -3994,46 +4009,56 @@ async function startSequence() { let activeJobs = 0; let completedJobs = 0; - await new Promise((resolve, reject) => { - function launchNextJobs() { - while (activeJobs < maxConcurrent && nextJob < jobs.length) { - const job = jobs[nextJob]; - nextJob += 1; - activeJobs += 1; - writeAsciiFile(All["workPath"]+"/"+job.filename+".inp", job.inputFileContent); - updateStatusBar({ - run: `Running ${completedJobs}/${jobs.length} completed, ${activeJobs} active` - }); - // Pass the selected RNG backend explicitly so sequential and - // parallel runs use the same generator choice. - executeFileAsync(trimBin, [job.filename, rngType], All["workPath"]) - .then(() => collectRunResult(job)) - .then(async (result) => { - results[result.index] = result; - activeJobs -= 1; - completedJobs += 1; - updateStatusBar({ - run: `Completed ${completedJobs}/${jobs.length}, ${activeJobs} active` - }); - await updateCompletedResults(); - if (completedJobs === jobs.length) { - resolve(); - return; - } - launchNextJobs(); - }) - .catch(reject); + try { + await new Promise((resolve, reject) => { + function launchNextJobs() { + while (activeJobs < maxConcurrent && nextJob < jobs.length) { + const job = jobs[nextJob]; + nextJob += 1; + activeJobs += 1; + updateStatusBar({ + run: `Running ${completedJobs}/${jobs.length} completed, ${activeJobs} active` + }); + // Pass the selected RNG backend explicitly so sequential and + // parallel runs use the same generator choice. + writeAsciiFileAsync(All["workPath"]+"/"+job.filename+".inp", job.inputFileContent) + .then((written) => { + if (!written) { throw new Error(`Could not submit ${job.filename}`); } + if (webOrApp) { return; } + return executeFileAsync(trimBin, [job.filename, rngType], All["workPath"]); + }) + .then(() => collectRunResult(job)) + .then(async (result) => { + results[result.index] = result; + activeJobs -= 1; + completedJobs += 1; + updateStatusBar({ + run: `Completed ${completedJobs}/${jobs.length}, ${activeJobs} active` + }); + await updateCompletedResults(); + if (completedJobs === jobs.length) { + resolve(); + return; + } + launchNextJobs(); + }) + .catch(reject); + } } - } - launchNextJobs(); - }); + launchNextJobs(); + }); + } catch (error) { + console.error('Scan job failed:', error); + updateStatusBar({ run: 'Run failed' }); + return(0); + } } let combinedSeqLines = buildCombinedSequenceLines(results); let seq_file = All["workPath"]+"/"+All["fileNamePrefix"]+"_Seq_Results.dat"; if (combinedSeqLines.length > 0) { - if (!writeAsciiFile(seq_file, combinedSeqLines.join('\n') + '\n')) { + if (!await writeAsciiFileAsync(seq_file, combinedSeqLines.join('\n') + '\n')) { return(0); } } @@ -4053,6 +4078,12 @@ function readDatFile(filename) { return parseDatContent(lines); } +async function readDatFileAsync(filename) { + // Async counterpart used by the scan pipeline in both web and Electron. + let lines = await readAsciiFileAsync(filename); + return parseDatContent(lines); +} + function parseDatContent(lines) { // Parse whitespace-separated column data from an in-memory string. let data = []; diff --git a/TrimSPweb.js b/TrimSPweb.js index cdc412d..64204b8 100644 --- a/TrimSPweb.js +++ b/TrimSPweb.js @@ -152,10 +152,12 @@ function recordWebDiagnostic(filename, request, response) { } } -function writeAsciiFile(filename,content) { +// Async file IO through the CGI. Using fetch instead of a synchronous +// XMLHttpRequest keeps the browser responsive while the server runs the +// simulation, and lets independent scan points run concurrently. +async function writeAsciiFileAsync(filename, content) { // Write string content into ascii file filename // Go via CGI script - //console.log(filename, content); // Prepare CGI args use POST for long files let cgiargs = new URLSearchParams(); cgiargs.set("fn", filename); @@ -171,20 +173,27 @@ function writeAsciiFile(filename,content) { cgiargs.set("line" + i, lines[i].replace(/\s\s+/g, ' ')); } - var xhttp; - xhttp = new XMLHttpRequest(); - let request = "/cgi-bin/singleTrimSP.cgi"; //POST - xhttp.open("POST", request, false); //POST - - //Send the proper header information along with the request - xhttp.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); //POST - xhttp.send(cgiargs.toString()); //POST - + let request = "/cgi-bin/singleTrimSP.cgi"; + let xhttp = { status: 0, statusText: "", responseText: "" }; let response = null; try { - response = JSON.parse(xhttp.responseText); + let fetched = await fetch(request, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: cgiargs.toString(), + cache: "no-store" + }); + xhttp.status = fetched.status; + xhttp.statusText = fetched.statusText; + xhttp.responseText = await fetched.text(); + try { + response = JSON.parse(xhttp.responseText); + } catch (error) { + console.error("Invalid CGI response", error, xhttp.responseText); + } } catch (error) { - console.error("Invalid CGI response", error, xhttp.responseText); + console.error("CGI request failed", error); + xhttp.statusText = error && error.message ? error.message : "Network error"; } recordWebDiagnostic(filename, xhttp, response); @@ -200,11 +209,11 @@ function writeAsciiFile(filename,content) { outputLink.title = `Download ${prefix[2]}.tgz`; outputLink.hidden = false; } - + return(1); } -function readAsciiFile(filename) { +async function readAsciiFileAsync(filename) { // Read server-side simulation output through the CGI. Files remain in // filesystem /tmp and do not need to be exposed directly by Apache. if (filename == "" || filename == undefined || filename == null) { @@ -213,10 +222,16 @@ function readAsciiFile(filename) { let request = "/cgi-bin/singleTrimSP.cgi?action=read&fn=" + encodeURIComponent(filename) + "&_=" + Date.now(); - let xhttp = new XMLHttpRequest(); - xhttp.open("GET", request, false); - xhttp.overrideMimeType("text/plain"); - xhttp.send(); + let xhttp = { status: 0, statusText: "", responseText: "" }; + try { + let fetched = await fetch(request, { cache: "no-store" }); + xhttp.status = fetched.status; + xhttp.statusText = fetched.statusText; + xhttp.responseText = await fetched.text(); + } catch (error) { + console.error("CGI read failed", error); + xhttp.statusText = error && error.message ? error.message : "Network error"; + } if (xhttp.status < 200 || xhttp.status >= 300) { recordWebDiagnostic(filename, xhttp, { diff --git a/main.js b/main.js index 6b3f5f8..4a984f0 100644 --- a/main.js +++ b/main.js @@ -194,7 +194,7 @@ function showQuickStartWindow(sectionId = 'helpLayers') { if (quickStartWindow && !quickStartWindow.isDestroyed()) { quickStartWindow.show(); quickStartWindow.focus(); - quickStartWindow.webContents.send('quickStartSection', { sectionId: sectionId }); + quickStartWindow.webContents.send('quickStartSection', { sectionId: sectionId, version: app.getVersion() }); return; } @@ -213,7 +213,7 @@ function showQuickStartWindow(sectionId = 'helpLayers') { } }); quickStartWindow.webContents.once('did-finish-load', () => { - quickStartWindow.webContents.send('quickStartSection', { sectionId: sectionId }); + quickStartWindow.webContents.send('quickStartSection', { sectionId: sectionId, version: app.getVersion() }); }); quickStartWindow.loadFile('quick-start.html'); quickStartWindow.on('closed', () => { diff --git a/quick-start.html b/quick-start.html index d165982..77a1352 100644 --- a/quick-start.html +++ b/quick-start.html @@ -22,7 +22,7 @@

Energy and parameter scans

Enable the scan, select its parameter, and choose either a regular range or an explicit set of values. The summary reports the number of points and workers before the run starts.

-

Electron can run scan points in parallel. The thread setting is local to the computer and is intentionally not stored in configuration files or shared links. The web backend executes scan points sequentially.

+

Scan points can run in parallel. The thread setting is local to the computer and is intentionally not stored in configuration files or shared links. In the web application the number of concurrent points is limited to protect the server.

@@ -42,6 +42,12 @@

The share button copies a link containing the current layers, projectile, scan, RNG, and advanced settings. Opening the link restores those inputs in the web application.

Output folders, file prefixes, worker counts, logs, and generated data are never included. The recipient chooses their own output location before running.

+ +
+

About TRIMSP-NL Workbench

+

TRIMSP-NL Workbench __TRIMSP_VERSION__ is a desktop and web workflow for low-energy muon and ion implantation simulations, combining the TRIM.SP-NL transport engine with the GUI and scan workflow.

+

Author: Zaher Salman. Copyright (C) 2026 Zaher Salman. GNU GPL version 3 or later. Source repository: gitea.psi.ch/LMU/TRIMSP.

+