Merge branch 'fix/scan-reliability' into master
1.6.0: web hardening (traversal fix, watchdog, pruner, archive lock, caps), scan input validation and RNG freeze, parIRL=1 crash fix, Electron plot fixes, and Plot menu no-focus crash fix.
This commit is contained in:
+2
-2
@@ -2,8 +2,8 @@ cff-version: 1.2.0
|
||||
message: "If you use this software, please cite the TRIMSP-NL Workbench publication and this software release."
|
||||
title: "TRIMSP-NL Workbench"
|
||||
type: software
|
||||
version: 1.5.0
|
||||
date-released: 2026-08-28
|
||||
version: 1.6.0
|
||||
date-released: 2026-10-09
|
||||
url: "http://musruser.psi.ch/TRIMSP-NL/"
|
||||
repository-code: "https://gitea.psi.ch/LMU/TRIMSP"
|
||||
license: GPL-3.0-or-later
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
Seed handling and input hardening (deferred from the B-list fixes)
|
||||
=====================================================================
|
||||
|
||||
DONE - Validate ranSeed in the GUI and startSequence: it must be an integer in a
|
||||
safe range (|v| <= 2^31-1, matching ISEED = IDINT(RI) in trimspNL.F). Enforced
|
||||
in runSimulationSequence() (before any job is generated) AND in CreateInpFile()
|
||||
(defense-in-depth), so a crafted share link or direct deck builder is blocked
|
||||
with a "please change it" message. Non-numeric / NaN / non-integer / oversized
|
||||
seeds no longer reach the engine.
|
||||
DONE - Derive per-scan-point seeds deterministically from the base seed and the
|
||||
scan index, so a multi-point run is reproducible in any finish order
|
||||
(currently: initialSeed + iScan, only when the user set a valid seed).
|
||||
DONE - Cap valEnergy magnitude at 60000 eV (positive incidence mode and the
|
||||
Maxwellian transmission mode |E0| = temperature). Stopping fits (KDEE1=4) are
|
||||
documented valid to ~50 keV for helium (meaning-of-params.txt:177-185), so 60
|
||||
keV is the model's fitted ceiling. Applied to scanned energies and the base
|
||||
value in runSimulationSequence() and CreateInpFile().
|
||||
- Warn when a shared configuration fixed the RanSeed: every scan point then uses
|
||||
the same generator, which can produce statistically misleading results.
|
||||
DONE (advisory, not a hard cap) - Warn on the web when numberProj exceeds 25000
|
||||
per point. Measured cost model on a 3.2 GHz core, single-threaded engine:
|
||||
runtime is linear in numberProj and linear in energy, and cost per projectile
|
||||
is dominated by the number of nuclear collisions along the track, so the
|
||||
expensive axis is the LIGHT projectiles, not the heavy ones. Projectile ->
|
||||
Si, Maxwellian 60 keV, ZBL potential, ms/projectile: muon 3.04, H 3.30,
|
||||
He 4.06 (worst), Li-8 3.91, Be-11 3.64, B-12 3.19, Mg-31 1.34, Ar 0.83.
|
||||
Checked and found NOT to affect runtime: layer count (1..60 layers is flat,
|
||||
the species lookup is a binary search), depth increment dz (0.1 A == 20 A),
|
||||
and grazing incidence (ALPHA 89 deg is cheaper - the ion skims out).
|
||||
NH=25000 at the worst case measured 98.6/99.5/100.1 s on the 1.3.2 binary and
|
||||
101.5/100.7/101.7 s on a fresh 1.4.0 build, i.e. ~3x margin under the 300 s
|
||||
TRIMSP_CMD_TIMEOUT. Deliberately warning-only: the value is advisory, the run
|
||||
still starts, and the user keeps the final say. Web-only, because the
|
||||
standalone app has no watchdog. TRIMSP_WEB_PROJ_ADVISORY in TrimSPlib.js;
|
||||
uses the max of the sweep when scanning numberProj. The dead GUI
|
||||
ProjNumberLimit() (TrimSPlib.js:3516) was left alone - it clamps at 5000,
|
||||
which is below the 50000-100000 used by the publication test configs, so
|
||||
wiring it up as-is would have clamped the regression suite.
|
||||
- Persisting the effective per-point seed: the seed already lives in each
|
||||
archived *.inp (all three deck slots), so reproducibility does not require the
|
||||
*.seq header change; extending *.seq would shift the 999(A20,1x) layer slots
|
||||
and break getSequenceColumnInfo(). Surfacing it in run details instead is
|
||||
deferred - not sure it is needed.
|
||||
|
||||
Out-of-bounds recoil write in the sputtering path (parIRL >= 1)
|
||||
=============================================================
|
||||
|
||||
FIXED - the engine now refuses the divergent input instead of corrupting memory.
|
||||
|
||||
Symptom, as originally reported: setting parIRL (the recoil/sputter output
|
||||
level) to 1 crashed the engine with SIGSEGV, reproducibly 5/5 seeds, and end
|
||||
to end through singleTrimSP.cgi as exitCode 139 / ok:false. parIRL=0 was never
|
||||
affected.
|
||||
|
||||
Root cause was NOT the KO dimension. The first diagnosis blamed
|
||||
KO(600,MAXNL5,2) at trimspNL.F:114 (debug: "Index '601' of dimension 1 of
|
||||
array 'ko' above upper bound of 600"), on the grounds that the other
|
||||
recoil-list arrays are 2000 wide. Enlarging KO to 2000 does not help: with
|
||||
KO(2000) the run still dies, because the cascade overruns all of them.
|
||||
|
||||
The actual cause is a non-converging cascade driven by parERC = 0. The
|
||||
primary knock-on test is `IF(DEN(IV).LE.ERC) GO TO 6`, so with a zero recoil
|
||||
cutoff every atom that receives any energy at all is respawned as a recoil on
|
||||
every cascade generation. The branching ratio exceeds one, so NREC1 and NREC2
|
||||
grow geometrically instead of draining:
|
||||
|
||||
NREC2 = 81, 153, 269, 455, 773, 1317, ... (parERC=0, parIRL=1)
|
||||
|
||||
and the list-2/list-1 loop at label 83 never terminates. Instrumented runs
|
||||
show the block is never even entered when parIRL=0. parERC defaults to 0.0
|
||||
in the GUI (TrimSP.html:293) and tests/Si_1layer/Si_1layer_E19000.inp carries
|
||||
0.0, so the failing combination is the default one - no unusual user action
|
||||
is required to hit it.
|
||||
|
||||
Evidence that a real cutoff converges: forcing parERC to 0.02, 0.05 or 0.2
|
||||
makes parIRL=1 finish normally (exit 0), and the peak cascade depth is only
|
||||
103, well inside KO's 600. KO was therefore left at 600, and the resize was
|
||||
reverted (it cost 7.7 MiB of .lbss and fixed nothing).
|
||||
|
||||
Changes in fortran/trimspNL.F:
|
||||
- After readGuiInput, reject parIRL>=1 with parERC<=0 using a clear message
|
||||
and STOP 1, before any transport work.
|
||||
- The `IF(NREC2.GT.2000)` guard now stops with a non-zero status instead of
|
||||
falling through into the out-of-bounds write loop, so any future
|
||||
non-convergent cascade fails cleanly rather than corrupting memory.
|
||||
|
||||
The GUI now catches this before the engine is ever invoked:
|
||||
- handleRecoilModeChange (wired to the parIRL widget) fills parERC with
|
||||
TRIMSP_MIN_RECOIL_CUTOFF = 0.02 when recoil calculation is switched on with
|
||||
a cutoff of 0, and says so in a transient warning.
|
||||
- A pre-run check in runSimulationSequence refuses the run if the user then
|
||||
insists on parIRL=1 with parERC=0, reports it in the status bar plus a
|
||||
transient warning, and focuses the parERC field. The handler is deliberately
|
||||
not wired to parERC, so retyping the cutoff is what "insisting" means and is
|
||||
not silently overridden. This also covers shared-configuration links, which
|
||||
assign widget values directly and so bypass the change handler.
|
||||
- The parIRL tooltip documents the requirement.
|
||||
|
||||
Verified: parERC=0/parIRL=1 -> exit 1 with the new message; parERC=0.02 with
|
||||
parIRL=0 or 1 -> exit 0; parERC=0 with parIRL=0 -> exit 0 and byte-identical
|
||||
.rge (and an identical .out apart from timestamps/elapsed/filenames) versus
|
||||
the pre-fix binary. tests/cgi-tests.sh 18/18, plus
|
||||
tests/recoil-cutoff-test.js (12 cases over the shipped cutoff code) for the
|
||||
GUI behaviour above.
|
||||
|
||||
Minimal repro: tests/Si_1layer/Si_1layer_E19000.inp with the last field of
|
||||
line 2 changed from 0 to 1 (parERC is field 10 of line 1, still 0.0).
|
||||
|
||||
Deferred follow-ups from the C-list CGI hardening
|
||||
=================================================
|
||||
|
||||
- The perl "Statement unlikely to be reached" warning at the exec/_exit(127)
|
||||
child branch of run_bounded_command is intentional (matching muSRFlow) and
|
||||
does not affect `perl -c`; tests run with -X to keep output clean.
|
||||
- No global concurrency cap is implemented (by design, mirroring muSRFlow):
|
||||
concurrent per-point engine runs are allowed; archive is serialized per run
|
||||
with flock. Revisit if a runaway share link floods scan points.
|
||||
- Auth (MUCH-need, muSRFlow parity): none is implemented, keeping the read-only
|
||||
/tmp contract. If this CGI is ever exposed beyond a trusted LAN, gate it with
|
||||
httpd authentication or a shared-token header.
|
||||
- Restart integration (retch): the watchdog returns exit code 124 and the JSON
|
||||
"log" carries the description; a deployment that wants automated retries can
|
||||
key off exitCode === 124 and re-post the same .inp.
|
||||
- A future client change could surface TRIMSP_TMPROOT behind a shared
|
||||
configuration; the browser path regex is derived from the same env var, so
|
||||
both sides stay consistent.
|
||||
|
||||
Follow-ups from the parIRL / parERC fix
|
||||
=======================================
|
||||
|
||||
- startSim() switches to the Results tab and purges the plots BEFORE
|
||||
runSimulationSequence() validates anything, so every refused run drops the
|
||||
user on an empty Results tab with no explanation of why. This predates the
|
||||
parIRL work and affects all the abort paths equally (energy cap, seed
|
||||
validation, recoil cutoff, and the re-entrancy guard in startSequence). Fix
|
||||
by validating in startSim() before the tab switch, or by moving the tab
|
||||
switch after the first successful job is queued.
|
||||
- The web deployment runs TRIMSP_BIN, which defaults to /usr/local/bin/trimspNL,
|
||||
so the engine-side fix in trimspNL.F does not reach the live site until the
|
||||
binary is rebuilt and installed. scripts/deploy-web.sh only rewrites the path
|
||||
in the CGI; it does not build. The GUI-side fix works immediately, so a
|
||||
deployment can briefly have the friendly GUI refusal in front of an engine
|
||||
that still segfaults on a hand-written deck.
|
||||
- Untracked scratch left in the tree, decide .gitignore vs delete:
|
||||
SrTiO3_E2000.inp (2 keV / 1000-projectile benchmark deck at the repo root),
|
||||
fortran/Makefile_org (Makefile backup), fortran/profile.txt and
|
||||
fortran/profile_annotated.txt (gprof reports; .gitignore covers gmon.out and
|
||||
fortran/trimspNL-profile but not these), and tests/pcg32-test/,
|
||||
tests/ranlux-test/, tests/xoshiro-test/ (per-backend engine run output trees
|
||||
from the RNG comparison, i.e. generated results rather than fixtures).
|
||||
|
||||
+4
-2
@@ -395,12 +395,14 @@
|
||||
No recoils are generated (i.e., no sputtering effects);
|
||||
used to speed up the calculation if only projectile ranges are of interest.
|
||||
</li>
|
||||
<li value="1">Calculate collision recoils.</li>
|
||||
<li value="1">Calculate collision recoils. Requires a
|
||||
parERC recoil cutoff above zero; 0.02 is filled in
|
||||
automatically.</li>
|
||||
</ol>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input name="parIRL" id="parIRL" type="number" step="1" min="0" max="1" value="0">
|
||||
<input name="parIRL" id="parIRL" type="number" step="1" min="0" max="1" value="0" onchange="handleRecoilModeChange();">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
+14
-9
@@ -2,6 +2,11 @@
|
||||
|
||||
// Load required packages
|
||||
const Plotly = require('plotly.js-dist');
|
||||
// Electron wraps every renderer <script> in its own CommonJS module scope, so
|
||||
// the bundled Plotly object must be exported to the global scope explicitly.
|
||||
// myplots.js and TrimSPlib.js call Plotly.plot/relayout/purge as a global on
|
||||
// the main window and inside the Plot-menu popup windows.
|
||||
window.Plotly = Plotly;
|
||||
const ipcRenderer = require('electron').ipcRenderer;
|
||||
const fs = require('fs');
|
||||
const { execSync, execFile } = require('child_process');
|
||||
@@ -94,14 +99,11 @@ function checkDir(directory) {
|
||||
console.log('Invalid directory passed to checkDir:', directory);
|
||||
return(0);
|
||||
}
|
||||
if (!fs.existsSync(directory)) {
|
||||
// Folder does not exist try to create it
|
||||
fs.mkdir(directory,{recursive: true}, (err) => {
|
||||
if (err) {
|
||||
alert('Cannot create data folder '+directory);
|
||||
return(0);
|
||||
}
|
||||
});
|
||||
try {
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
} catch (err) {
|
||||
alert('Cannot create data folder '+directory);
|
||||
return(0);
|
||||
}
|
||||
return(1);
|
||||
}
|
||||
@@ -148,7 +150,10 @@ async function readAsciiFileAsync(filename) {
|
||||
return await fs.promises.readFile(filename, 'utf8');
|
||||
} catch(e) {
|
||||
alert('Failed to read '+filename);
|
||||
return(0);
|
||||
// Match the web backend contract: the scan pipeline awaits this and
|
||||
// expects a rejected promise, not a falsy value that would later fail
|
||||
// with a confusing "0.trim is not a function" error.
|
||||
throw new Error('Failed to read '+filename);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+342
-50
@@ -7,6 +7,11 @@ const electronExecSync = (typeof module !== 'undefined' && module.exports)
|
||||
// This is an object that contains all GUI ids and values
|
||||
var All = new Object();
|
||||
|
||||
// True while a simulation scan is running. Blocks re-entrant Run clicks that
|
||||
// would otherwise reassign the global All and let two scans write the same
|
||||
// files concurrently.
|
||||
var runInProgress = false;
|
||||
|
||||
|
||||
function isObject(item) {
|
||||
// simple object check
|
||||
@@ -3049,16 +3054,25 @@ function parse_formula (mf) {
|
||||
if(/^\d+/.test(mf)) throw new SyntaxError("Molecular formula should not start with a number!");
|
||||
var mol = {};
|
||||
// go into brackets and collect info
|
||||
var bracketGuard = 0;
|
||||
while(mf.includes("(")){
|
||||
if (++bracketGuard > 100) {
|
||||
throw new SyntaxError("Brackets are nested too deeply in this formula");
|
||||
}
|
||||
var x = mf.lastIndexOf("(");
|
||||
var y = mf.indexOf(")", mf.lastIndexOf("("));
|
||||
var y = mf.indexOf(")", x);
|
||||
if (y === -1) {
|
||||
// An opening bracket without a matching closing one would otherwise
|
||||
// rebuild the same string forever and freeze the tab.
|
||||
throw new SyntaxError("Unbalanced parentheses in formula");
|
||||
}
|
||||
|
||||
var z = mf.substr(x+1, y-x-1);
|
||||
var c = mf.substr(y+1);
|
||||
c = /^\d+/.exec(c);
|
||||
c=(c!= null)?Number(c[0]):1;
|
||||
// recursive
|
||||
var a = this.parse_formula(z);
|
||||
var a = parse_formula(z);
|
||||
var b = "";
|
||||
for(var k in a){
|
||||
b+=k+""+a[k]*c;
|
||||
@@ -3254,7 +3268,14 @@ function rho_fun()
|
||||
markDensityEstimated(rhoLi, false);
|
||||
} else {
|
||||
// suggest a material density based on composition (when it's undefined)
|
||||
let layer_formula = parse_formula(chem);
|
||||
let layer_formula;
|
||||
try {
|
||||
layer_formula = parse_formula(chem);
|
||||
} catch (parseError) {
|
||||
showTransientWarning("Could not parse composition: " + parseError.message, document.getElementById(rhoLi));
|
||||
document.getElementById(rhoLi).value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// determine the stoichiometry sum (for normalization)
|
||||
let stoichiometry_sum = 0;
|
||||
@@ -3434,18 +3455,15 @@ function updateScanSummary()
|
||||
var warning = "";
|
||||
|
||||
if (scanType === "scanLoop") {
|
||||
var from = Number(document.getElementById("scanFrom").value);
|
||||
var step = Number(document.getElementById("scanStep").value);
|
||||
var to = Number(document.getElementById("scanTo").value);
|
||||
if (!Number.isFinite(from) || !Number.isFinite(step) || !Number.isFinite(to)) {
|
||||
warning = "Enter numeric From, Step, and To values.";
|
||||
} else if (step <= 0) {
|
||||
warning = "Step must be greater than zero.";
|
||||
} else if (to < from) {
|
||||
warning = "To must be greater than or equal to From.";
|
||||
var range = rangeScanValues(
|
||||
document.getElementById("scanFrom").value,
|
||||
document.getElementById("scanStep").value,
|
||||
document.getElementById("scanTo").value);
|
||||
if (range.error) {
|
||||
warning = range.error;
|
||||
} else {
|
||||
pointCount = Math.floor((to - from) / step) + 1;
|
||||
detail = `${from} to ${to}, step ${step}`;
|
||||
pointCount = range.values.length;
|
||||
detail = `${range.values[0]} to ${range.values[pointCount - 1]}, step ${document.getElementById("scanStep").value}`;
|
||||
}
|
||||
} else {
|
||||
var values = document.getElementById("scanList").value
|
||||
@@ -3595,22 +3613,37 @@ function setValues(content) {
|
||||
flines = lines.filter(function (str) {return PATTERN.test(str);});
|
||||
for (var i=0; i<flines.length; i++) {
|
||||
// Loop over parameters, set appropriate values and trigger associated onChange
|
||||
var param=flines[i].replace(/\s+/,'').split('=');
|
||||
if (param[0] == 'scanSeq') {
|
||||
var splitIdx = flines[i].indexOf('=');
|
||||
if (splitIdx == -1) {continue;}
|
||||
var key = flines[i].slice(0, splitIdx).trim();
|
||||
var value = flines[i].slice(splitIdx + 1).trim();
|
||||
var input = document.getElementById(key);
|
||||
if (!input) {continue;}
|
||||
if (key == 'scanSeq') {
|
||||
// This is a checkbox
|
||||
var seqChecked = true;
|
||||
if (param[1] == 0) {seqChecked = false;}
|
||||
document.getElementById(param[0]).checked = seqChecked;
|
||||
document.getElementById(param[0]).dispatchEvent(event);
|
||||
if (value == '0' || value == 'false') {seqChecked = false;}
|
||||
input.checked = seqChecked;
|
||||
input.dispatchEvent(event);
|
||||
} else {
|
||||
document.getElementById(param[0]).value = param[1];
|
||||
document.getElementById(param[0]).dispatchEvent(event);
|
||||
input.value = value;
|
||||
input.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function CreateInpFile() {
|
||||
// Gate client-controlled magnitudes here too so a crafted config can never
|
||||
// reach the engine, regardless of which caller builds the deck.
|
||||
var inpSeedError = validateSeedString(All["ranSeed"]);
|
||||
if (inpSeedError) {
|
||||
throw new Error(inpSeedError);
|
||||
}
|
||||
var inpEnergyError = validateEnergyValue(All["valEnergy"]);
|
||||
if (inpEnergyError) {
|
||||
throw new Error(inpEnergyError);
|
||||
}
|
||||
// Create and return an input deck for the TRIM.SP-NL transport engine.
|
||||
// Takes object All as input for all relevant parameters
|
||||
|
||||
@@ -3629,6 +3662,10 @@ function CreateInpFile() {
|
||||
// they now support any number of elements up to the Fortran MAXEL.
|
||||
|
||||
let projectile = All['ProjType'];
|
||||
if (!projectile || !elemPars[projectile] || elemPars[projectile].Z == ""
|
||||
|| elemPars[projectile].Z === undefined) {
|
||||
return { ok: false, error: 'Unknown projectile "' + projectile + '".' };
|
||||
}
|
||||
All['ProjZ'] = elemPars[projectile].Z;
|
||||
All['ProjAM'] = elemPars[projectile].A;
|
||||
|
||||
@@ -3651,10 +3688,15 @@ function CreateInpFile() {
|
||||
compField.value = Comp;
|
||||
}
|
||||
if (Comp == "" || typeof Comp == 'undefined') {Check++;}
|
||||
let LElComp=parse_formula(Comp);
|
||||
let LElComp;
|
||||
try {
|
||||
LElComp = parse_formula(Comp);
|
||||
} catch (error) {
|
||||
return { ok: false, error: "Bad chemical formula in layer "+i+": " + error.message };
|
||||
}
|
||||
for (key of Object.keys(LElComp)) {
|
||||
// Check if composition is understood
|
||||
if (key=="" || elemPars[key].Z =="") {
|
||||
if (key=="" || !elemPars[key] || elemPars[key].Z == "") {
|
||||
Check++;
|
||||
}
|
||||
}
|
||||
@@ -3666,20 +3708,18 @@ function CreateInpFile() {
|
||||
|
||||
// Densities of layers
|
||||
let Lrho="L"+i+"rho";
|
||||
let rho = 1*All[Lrho];
|
||||
let rho = Number(All[Lrho]);
|
||||
|
||||
if (rho==0) {Check++;}
|
||||
if (!Number.isFinite(rho) || rho==0) {Check++;}
|
||||
|
||||
// Thickness of layers
|
||||
let Ld ="L"+i+"d";
|
||||
let d = All[Ld];
|
||||
if (d=="") {Check++;}
|
||||
if (d=="" || d===undefined || !Number.isFinite(Number(d))) {Check++;}
|
||||
|
||||
// Sanity check, is the layer supposed to have value? are they all there?
|
||||
if (Check!=0) {
|
||||
ErrMsg="Error: Bad chemical formula in Layer $i.\nPlease check!\n";
|
||||
console.log($ErrMsg);
|
||||
return ErrMsg;
|
||||
return { ok: false, error: "Error: invalid layer "+i+".\nPlease check the composition, density, and thickness.\n" };
|
||||
}
|
||||
|
||||
let Sum = 0;
|
||||
@@ -3713,12 +3753,15 @@ function CreateInpFile() {
|
||||
}
|
||||
|
||||
for (let key of Object.keys(All)) {
|
||||
if (All[key]!=null){
|
||||
if (All[key] != null){
|
||||
// A function replacer is used so values are inserted verbatim: a
|
||||
// string search value containing "$&", "$`", or "$'" would otherwise
|
||||
// be interpreted as a replacement pattern and splice in template text.
|
||||
if (key=="ranSeed") {
|
||||
// Seed repeats three times
|
||||
TemplateFile = TemplateFile.replace(/ranSeed/g,All[key]);
|
||||
TemplateFile = TemplateFile.replace(/ranSeed/g, () => All[key]);
|
||||
} else {
|
||||
TemplateFile = TemplateFile.replace(key,All[key]);
|
||||
TemplateFile = TemplateFile.replace(key, () => All[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3743,6 +3786,125 @@ function yieldToRenderer() {
|
||||
// points in parallel.
|
||||
var TRIMSP_WEB_MAX_CONCURRENCY = 4;
|
||||
|
||||
// Upper bound on the number of points one scan may expand to. This guards
|
||||
// against a crafted link or an accidental step that would otherwise generate
|
||||
// an effectively unlimited list of jobs and freeze the tab.
|
||||
var TRIMSP_MAX_SCAN_POINTS = 5000;
|
||||
|
||||
// Validated limits for client-owned input values. The Ziegler/ICRU stopping
|
||||
// fits are only tuned up to ~50 keV for helium (KDEE1=4, see
|
||||
// meaning-of-params.txt:177-185), so energies beyond 60 keV are outside the
|
||||
// model's fitted validity. Negative energies select the Maxwellian initial
|
||||
// distribution where |E0| is used as the ion temperature (trimspNL.F:806), so
|
||||
// the same magnitude cap applies to both modes.
|
||||
var TRIMSP_ENERGY_CAP_EV = 60000;
|
||||
|
||||
// The engine casts the deck seed to a signed 32-bit integer
|
||||
// (ISEED = IDINT(RI), trimspNL.F:818-827), so any magnitude beyond that is
|
||||
// silently truncated. Restrict the seed to |v| <= 2^31-1.
|
||||
var TRIMSP_SEED_MAX_ABS = 2147483647;
|
||||
|
||||
// Advisory projectile count for the web backend. This is not a hard limit: the
|
||||
// engine is single threaded and its cost per projectile is dominated by the
|
||||
// number of nuclear collisions along the track, so the same count can differ by
|
||||
// more than 5x across the projectile menu. Measured worst case is helium into a
|
||||
// light heavy-ish target at the 60 keV energy cap with the ZBL potential, at
|
||||
// about 4.0 ms per projectile on a 3.2 GHz core, which puts 25000 points at
|
||||
// roughly 100 s. The web backend watchdog (TRIMSP_CMD_TIMEOUT, 300 s) then
|
||||
// still has about a 3x margin, so this count normally completes while slower or
|
||||
// more heavily loaded deployments keep the watchdog as a backstop. The warning
|
||||
// is advisory only; the user keeps the final say.
|
||||
var TRIMSP_WEB_PROJ_ADVISORY = 25000;
|
||||
|
||||
// Recoil cutoff substituted when the user turns on recoil calculation with a
|
||||
// cutoff of zero. A zero cutoff makes the transport respawn every recoiling
|
||||
// atom on every cascade generation, so NREC1/NREC2 grow geometrically instead
|
||||
// of draining and the run overruns the recoil arrays. The engine refuses that
|
||||
// combination, so the GUI supplies a usable value instead of letting the run
|
||||
// fail after the user presses Run.
|
||||
var TRIMSP_MIN_RECOIL_CUTOFF = 0.02;
|
||||
|
||||
// True when the recoil calculation is enabled without a usable recoil cutoff.
|
||||
// The parIRL and parERC widgets are plain number inputs and a shared
|
||||
// configuration assigns their values directly, so this can be reached without
|
||||
// going through the change handler below.
|
||||
function recoilCutoffIsUnusable() {
|
||||
let irl = Number(document.getElementById("parIRL").value);
|
||||
let cutoff = Number(document.getElementById("parERC").value);
|
||||
return Number.isFinite(irl) && irl >= 1
|
||||
&& (!Number.isFinite(cutoff) || cutoff <= 0);
|
||||
}
|
||||
|
||||
// Called when the parIRL widget changes. Turning recoil calculation on with no
|
||||
// cutoff is filled in for the user here, because that is a value they almost
|
||||
// never intend to type. Deliberately not wired to parERC: retyping the cutoff
|
||||
// afterwards is how a user insists on the combination, and the pre-run check
|
||||
// then refuses the run instead of silently overriding them.
|
||||
function handleRecoilModeChange() {
|
||||
if (!recoilCutoffIsUnusable()) {
|
||||
return;
|
||||
}
|
||||
let cutoffField = document.getElementById("parERC");
|
||||
cutoffField.value = TRIMSP_MIN_RECOIL_CUTOFF;
|
||||
showTransientWarning(
|
||||
"Recoil calculation needs a cutoff, so parERC was set to "
|
||||
+ TRIMSP_MIN_RECOIL_CUTOFF + ".", cutoffField, 6000);
|
||||
}
|
||||
|
||||
// Returns an empty string when valid, otherwise a message that asks the user
|
||||
// to change the offending value.
|
||||
function validateEnergyValue(raw) {
|
||||
var str = String(raw).trim();
|
||||
var value = Number(str);
|
||||
if (str === "" || !Number.isFinite(value)) {
|
||||
return "Energy must be numeric. Please change it.";
|
||||
}
|
||||
if (Math.abs(value) > TRIMSP_ENERGY_CAP_EV) {
|
||||
return "Energy magnitude must not exceed 60000 eV (the stopping fits are not validated beyond ~50 keV). Please change it.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateSeedString(raw) {
|
||||
var str = String(raw).trim();
|
||||
if (str === "" || !/^[+-]?\d+$/.test(str)) {
|
||||
return "Seed must be an integer. Please change it.";
|
||||
}
|
||||
var value = Number(str);
|
||||
if (Math.abs(value) > TRIMSP_SEED_MAX_ABS) {
|
||||
return "Seed is out of range (use an integer between -2147483648 and 2147483647). Please change it.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Expand a From/Step/To range into a list of scan values. Both ascending and
|
||||
// descending steps are valid (e.g. scanning from high to low energy), so the
|
||||
// old "loop while value <= to" form is replaced by a count-based expansion
|
||||
// that avoids zero-step and floating-point drift. Returns {values} or {error}.
|
||||
function rangeScanValues(fromText, stepText, toText) {
|
||||
var from = Number(fromText);
|
||||
var step = Number(stepText);
|
||||
var to = Number(toText);
|
||||
if (!Number.isFinite(from) || !Number.isFinite(step) || !Number.isFinite(to)) {
|
||||
return { error: "Enter numeric From, Step, and To values." };
|
||||
}
|
||||
if (step === 0) {
|
||||
return { error: "Step must be non-zero." };
|
||||
}
|
||||
if ((step > 0 && to < from) || (step < 0 && to > from)) {
|
||||
return { error: "To must be reachable from From with the given Step." };
|
||||
}
|
||||
var count = Math.floor(Math.abs((to - from) / step)) + 1;
|
||||
if (count > TRIMSP_MAX_SCAN_POINTS) {
|
||||
return { error: "Too many scan points (max " + TRIMSP_MAX_SCAN_POINTS + ")." };
|
||||
}
|
||||
var values = [];
|
||||
for (var i = 0; i < count; i++) {
|
||||
values.push(Number((from + i * step).toPrecision(12)));
|
||||
}
|
||||
return { values: values };
|
||||
}
|
||||
|
||||
// Format the live profile legend from the scan parameter currently being run.
|
||||
function formatScanLegend(scanName, scanValue) {
|
||||
if (scanName === "E") {
|
||||
@@ -3814,7 +3976,7 @@ function renderCompletedScanResults(results, allValues) {
|
||||
return combinedSeqLines;
|
||||
}
|
||||
|
||||
async function startSequence() {
|
||||
async function runSimulationSequence() {
|
||||
let cmd = '';
|
||||
let trimBin = All['trimPath']+"/trimspNL";
|
||||
let randStr = '';
|
||||
@@ -3850,7 +4012,12 @@ async function startSequence() {
|
||||
updateStatusBar({ backend: 'Web backend' });
|
||||
}
|
||||
updateStatusBar({ workPath: All["workPath"], run: 'Starting run...' });
|
||||
if (!await writeAsciiFileAsync(All["workPath"] + "/TrimSP.cfg", prep_cfg(0))) {
|
||||
// prep_cfg() refreshes All from the DOM, so freeze the RNG backend now.
|
||||
// The browser must ignore dropdown changes that happen while CGI requests
|
||||
// are still in flight, otherwise one scan could mix generators and produce
|
||||
// irreproducible results.
|
||||
const rngType = All["rngType"] || "pcg32";
|
||||
if (!await writeAsciiFileAsync(All["workPath"] + "/TrimSP.cfg", prep_cfg(0), rngType)) {
|
||||
return(0);
|
||||
}
|
||||
|
||||
@@ -3863,16 +4030,34 @@ async function startSequence() {
|
||||
var SValues = [];
|
||||
let ScanName = "E";
|
||||
let ScanAttrib = "valEnergy";
|
||||
let scanError = "";
|
||||
// Generate an arry of scanned values (one element for a single run)
|
||||
if (All["scanSeq"]) {
|
||||
// For multiple runs or a scan
|
||||
if (All["scanType"]=="scanVals") {
|
||||
var SValues=All["scanList"].split(/,/);
|
||||
var SdzValues=All["scanListdz"].split(/,/);
|
||||
SValues = All["scanList"].split(/,/)
|
||||
.map(function(v) {return v.trim();})
|
||||
.filter(function(v) {return v !== "";})
|
||||
.map(Number);
|
||||
var SdzValues = All["scanListdz"].split(/,/)
|
||||
.map(function(v) {return v.trim();})
|
||||
.filter(function(v) {return v !== "";});
|
||||
if (SValues.length == SdzValues.length) {All["SdzFlag"]=1;}
|
||||
if (SValues.length === 0) {
|
||||
scanError = "Enter at least one scan value.";
|
||||
} else if (SValues.some(function(v) {return !Number.isFinite(v);})) {
|
||||
scanError = "Every scan value must be numeric.";
|
||||
} else if (SValues.length > TRIMSP_MAX_SCAN_POINTS) {
|
||||
scanError = "Too many scan values (max " + TRIMSP_MAX_SCAN_POINTS + ").";
|
||||
}
|
||||
} else {
|
||||
for (let Val=parseInt(All["scanFrom"]);Val<=parseInt(All["scanTo"]);Val=Val+parseInt(All["scanStep"])) {
|
||||
SValues.push(Val);
|
||||
// rangeScanValues handles both ascending and descending steps and
|
||||
// rejects zero-size or unreachable ranges before expanding.
|
||||
var range = rangeScanValues(All["scanFrom"], All["scanStep"], All["scanTo"]);
|
||||
if (range.error) {
|
||||
scanError = range.error;
|
||||
} else {
|
||||
SValues = range.values;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3895,7 +4080,67 @@ async function startSequence() {
|
||||
}
|
||||
} else {
|
||||
// For single run, array with single value of valEnergy
|
||||
SValues.push(parseInt(All["valEnergy"]));
|
||||
var singleEnergy = Number(All["valEnergy"]);
|
||||
if (!Number.isFinite(singleEnergy)) {
|
||||
scanError = "Energy must be numeric.";
|
||||
}
|
||||
SValues.push(singleEnergy);
|
||||
}
|
||||
if (scanError) {
|
||||
updateStatusBar({ run: scanError });
|
||||
return(0);
|
||||
}
|
||||
|
||||
// Cap the projectile energy magnitude before any job is generated. For an
|
||||
// energy sweep the scan values are authoritative; for any other sweep the
|
||||
// base valEnergy must still stay in the fitting range.
|
||||
var evalues = (ScanAttrib === "valEnergy") ? SValues : [Number(All["valEnergy"])];
|
||||
for (var evIndex = 0; evIndex < evalues.length; evIndex++) {
|
||||
var energyError = validateEnergyValue(evalues[evIndex]);
|
||||
if (energyError) {
|
||||
updateStatusBar({ run: energyError });
|
||||
return(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Advisory only, and web only: the standalone app runs the engine locally
|
||||
// with no watchdog, so a long run is the user's own choice there. A number
|
||||
// of projectiles that scales badly with the collision count can still exceed
|
||||
// the backend watchdog, so warn once per run without blocking it. For a
|
||||
// numberProj sweep the scan values are authoritative, otherwise the base
|
||||
// value is what every point uses.
|
||||
if (amIWeb()) {
|
||||
const projValues = (ScanAttrib === "numberProj")
|
||||
? SValues
|
||||
: [Number(All["numberProj"])];
|
||||
let worstProj = 0;
|
||||
for (var projIndex = 0; projIndex < projValues.length; projIndex++) {
|
||||
let projValue = Number(projValues[projIndex]);
|
||||
if (Number.isFinite(projValue) && projValue > worstProj) {
|
||||
worstProj = projValue;
|
||||
}
|
||||
}
|
||||
if (worstProj > TRIMSP_WEB_PROJ_ADVISORY) {
|
||||
showTransientWarning(
|
||||
"Warning: " + worstProj + " projectiles per point may exceed the web "
|
||||
+ "time limit and fail. Runs above ~" + TRIMSP_WEB_PROJ_ADVISORY
|
||||
+ " are safest; the run will start anyway.",
|
||||
document.getElementById("numberProj"), 8000);
|
||||
}
|
||||
}
|
||||
|
||||
// Hard stop, unlike the projectile advisory above: with a zero recoil
|
||||
// cutoff the cascade does not converge, so the engine exits with an error
|
||||
// instead of producing results. Refuse the run here and say which field to
|
||||
// change, rather than letting the backend fail with a transport message.
|
||||
if (recoilCutoffIsUnusable()) {
|
||||
let cutoffField = document.getElementById("parERC");
|
||||
let message = "parIRL=1 needs a parERC recoil cutoff above 0; "
|
||||
+ TRIMSP_MIN_RECOIL_CUTOFF + " is a common choice.";
|
||||
updateStatusBar({ run: message });
|
||||
showTransientWarning("Not running: " + message, cutoffField, 8000);
|
||||
cutoffField.focus();
|
||||
return(0);
|
||||
}
|
||||
|
||||
// Freeze the initial GUI state and expand the scan into independent jobs.
|
||||
@@ -3904,6 +4149,11 @@ async function startSequence() {
|
||||
const scanValues = SValues.slice();
|
||||
const initialAll = Object.assign({}, All);
|
||||
const initialSeed = parseInt(initialAll["ranSeed"], 10);
|
||||
const seedError = validateSeedString(initialAll["ranSeed"]);
|
||||
if (seedError) {
|
||||
updateStatusBar({ run: seedError });
|
||||
return(0);
|
||||
}
|
||||
let jobs = [];
|
||||
|
||||
for (let iScan = 0; iScan < scanValues.length; iScan++) {
|
||||
@@ -3914,8 +4164,23 @@ async function startSequence() {
|
||||
jobAll["ranSeed"] = String(initialSeed + iScan);
|
||||
}
|
||||
All = jobAll;
|
||||
let inputFileContent = CreateInpFile();
|
||||
if (inputFileContent=="ERROR") {
|
||||
let inputFileContent;
|
||||
try {
|
||||
inputFileContent = CreateInpFile();
|
||||
} catch (error) {
|
||||
let message = (error && error.message) ? error.message : String(error);
|
||||
updateStatusBar({ run: message });
|
||||
All = initialAll;
|
||||
return(0);
|
||||
}
|
||||
if (inputFileContent && typeof inputFileContent === "object"
|
||||
&& inputFileContent.ok === false) {
|
||||
updateStatusBar({ run: inputFileContent.error || "Could not build the input file." });
|
||||
All = initialAll;
|
||||
return(0);
|
||||
}
|
||||
if (typeof inputFileContent !== "string" || inputFileContent.length === 0) {
|
||||
updateStatusBar({ run: "Could not build the input file." });
|
||||
All = initialAll;
|
||||
return(0);
|
||||
}
|
||||
@@ -3930,6 +4195,10 @@ async function startSequence() {
|
||||
}
|
||||
All = initialAll;
|
||||
|
||||
// Results must land in the work dir that existed when the run started,
|
||||
// never in or next to whatever All points at if a second Run was attempted.
|
||||
const runWorkPath = initialAll["workPath"];
|
||||
|
||||
// Results are stored by original scan index. The plots and final merged
|
||||
// sequence file are always rebuilt from this ordered array.
|
||||
let results = new Array(jobs.length);
|
||||
@@ -3937,7 +4206,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]= await readDatFileAsync(All["workPath"]+"/"+job.filename+".rge");
|
||||
let [cols,data]= await readDatFileAsync(runWorkPath+"/"+job.filename+".rge");
|
||||
let depth=data[0];
|
||||
let nmuons=data[1];
|
||||
let dz = (depth[1]-depth[0])/10;
|
||||
@@ -3946,7 +4215,7 @@ async function startSequence() {
|
||||
depth[i]=depth[i]/10;
|
||||
nmuons[i]=nmuons[i]/norm;
|
||||
}
|
||||
let seqRunFile = All["workPath"]+"/"+job.filename+".seq";
|
||||
let seqRunFile = runWorkPath+"/"+job.filename+".seq";
|
||||
let seqRunLines = (await readAsciiFileAsync(seqRunFile)).trim().split('\n');
|
||||
return {
|
||||
index: job.index,
|
||||
@@ -3972,18 +4241,18 @@ async function startSequence() {
|
||||
// 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 (!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.
|
||||
try {
|
||||
for (const job of jobs) {
|
||||
if (!await writeAsciiFileAsync(All["workPath"]+"/"+job.filename+".inp", job.inputFileContent)) {
|
||||
if (!await writeAsciiFileAsync(runWorkPath+"/"+job.filename+".inp", job.inputFileContent, rngType)) {
|
||||
return(0);
|
||||
}
|
||||
if (!webOrApp) {
|
||||
cmd = "cd " + All["workPath"];
|
||||
cmd = "cd " + runWorkPath;
|
||||
cmd += ";" + trimBin + ' ' + job.filename + ' ' + rngType;
|
||||
electronExecSync(cmd, {stdio: 'inherit'});
|
||||
}
|
||||
@@ -3991,6 +4260,11 @@ async function startSequence() {
|
||||
updateStatusBar({ run: `Completed ${results.filter(Boolean).length}/${jobs.length}: ${job.legend}` });
|
||||
await updateCompletedResults();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Scan job failed:', error);
|
||||
updateStatusBar({ run: 'Run failed' });
|
||||
return(0);
|
||||
}
|
||||
} else {
|
||||
// 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
|
||||
@@ -4021,11 +4295,11 @@ async function startSequence() {
|
||||
});
|
||||
// Pass the selected RNG backend explicitly so sequential and
|
||||
// parallel runs use the same generator choice.
|
||||
writeAsciiFileAsync(All["workPath"]+"/"+job.filename+".inp", job.inputFileContent)
|
||||
writeAsciiFileAsync(runWorkPath+"/"+job.filename+".inp", job.inputFileContent, rngType)
|
||||
.then((written) => {
|
||||
if (!written) { throw new Error(`Could not submit ${job.filename}`); }
|
||||
if (webOrApp) { return; }
|
||||
return executeFileAsync(trimBin, [job.filename, rngType], All["workPath"]);
|
||||
return executeFileAsync(trimBin, [job.filename, rngType], runWorkPath);
|
||||
})
|
||||
.then(() => collectRunResult(job))
|
||||
.then(async (result) => {
|
||||
@@ -4056,9 +4330,9 @@ async function startSequence() {
|
||||
}
|
||||
|
||||
let combinedSeqLines = buildCombinedSequenceLines(results);
|
||||
let seq_file = All["workPath"]+"/"+All["fileNamePrefix"]+"_Seq_Results.dat";
|
||||
let seq_file = runWorkPath+"/"+initialAll["fileNamePrefix"]+"_Seq_Results.dat";
|
||||
if (combinedSeqLines.length > 0) {
|
||||
if (!await writeAsciiFileAsync(seq_file, combinedSeqLines.join('\n') + '\n')) {
|
||||
if (!await writeAsciiFileAsync(seq_file, combinedSeqLines.join('\n') + '\n', rngType)) {
|
||||
return(0);
|
||||
}
|
||||
}
|
||||
@@ -4070,6 +4344,24 @@ async function startSequence() {
|
||||
return(0);
|
||||
}
|
||||
|
||||
async function startSequence() {
|
||||
// Re-entrancy guard: starting a second run while one is in flight would
|
||||
// reassign the global All and let two scans write the same server files.
|
||||
if (runInProgress) {
|
||||
updateStatusBar({ run: 'A run is already in progress.' });
|
||||
return(0);
|
||||
}
|
||||
runInProgress = true;
|
||||
const runButton = document.getElementById("runSimulationButton");
|
||||
if (runButton) { runButton.disabled = true; }
|
||||
try {
|
||||
return await runSimulationSequence();
|
||||
} finally {
|
||||
runInProgress = false;
|
||||
if (runButton) { runButton.disabled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
function readDatFile(filename) {
|
||||
// Read column data file and return
|
||||
// cols - labels of columns
|
||||
|
||||
+11
-7
@@ -155,22 +155,24 @@ function recordWebDiagnostic(filename, request, response) {
|
||||
// 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) {
|
||||
async function writeAsciiFileAsync(filename, content, rngType) {
|
||||
// Write string content into ascii file filename
|
||||
// Go via CGI script
|
||||
// Prepare CGI args use POST for long files
|
||||
let cgiargs = new URLSearchParams();
|
||||
cgiargs.set("fn", filename);
|
||||
// Forward the GUI-selected RNG backend to the CGI wrapper, which passes it
|
||||
// to the TRIM.SP-NL engine as the optional second command-line argument.
|
||||
let rngType = document.getElementById("rngType");
|
||||
// Forward the RNG backend that was frozen when the run started, so all
|
||||
// points of one scan use the same generator even if the GUI dropdown is
|
||||
// changed while CGI requests are still in flight.
|
||||
if (rngType) {
|
||||
cgiargs.set("rngType", rngType.value);
|
||||
cgiargs.set("rngType", rngType);
|
||||
}
|
||||
let lines = content.split(/\n/);
|
||||
let prefix = filename.split(/\//);
|
||||
for (let i=0; i<lines.length; i++) {
|
||||
cgiargs.set("line" + i, lines[i].replace(/\s\s+/g, ' '));
|
||||
// Write the content verbatim so web and Electron produce byte-identical
|
||||
// input files for the same configuration.
|
||||
cgiargs.set("line" + i, lines[i]);
|
||||
}
|
||||
|
||||
let request = "/cgi-bin/singleTrimSP.cgi";
|
||||
@@ -217,7 +219,9 @@ 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) {
|
||||
return 0;
|
||||
// Unify the error contract: like the Electron backend, fail loudly
|
||||
// instead of returning a falsy value that breaks the caller at read time.
|
||||
throw new Error("Invalid empty filename for readAsciiFileAsync");
|
||||
}
|
||||
|
||||
let request = "/cgi-bin/singleTrimSP.cgi?action=read&fn="
|
||||
|
||||
@@ -111,6 +111,12 @@ C
|
||||
& ,ISPIS(MAXNL5),ISPOP(MAXNL5),ISPOS(MAXNL5)
|
||||
INTEGER*4 ITSP(MAXNL5),ISPALT(MAXNL)
|
||||
& ,ISPIPT(MAXNL5),ISPIST(MAXNL5),ISPOPT(MAXNL5),ISPOST(MAXNL5)
|
||||
C KO flags each recoil as a primary knock-on or a cascade product. Its
|
||||
C first index is a recoil-list index, and 600 is deep enough for every
|
||||
C cutoff that yields a converging cascade (measured peak is 103 with
|
||||
C ERC=0.02). See the ERC/IRL check after readGuiInput below: with a zero
|
||||
C recoil cutoff the cascade does not converge at all, and it overruns
|
||||
C these 600 entries as well as the 2000-deep recoil lists.
|
||||
INTEGER*4 KO(600,MAXNL5,2)
|
||||
INTEGER*4 MEAB(MAXD2,22),MAGB(62,22),MEAGB(MAXD2,36,22)
|
||||
& ,MEABL(75,21),MEPB(MAXD2,MAXD2)
|
||||
@@ -569,6 +575,24 @@ C GUI file layout is isolated from the transport code.
|
||||
& ED,BE,CH1,CH2,CH3,CH4,CH5)
|
||||
|
||||
|
||||
C A zero recoil cutoff makes the cascade diverge. The primary knock-on
|
||||
C test below is IF(DEN(IV).LE.ERC) GO TO 6, so with ERC=0 every atom that
|
||||
C receives any energy at all is respawned as a recoil on every cascade
|
||||
C generation. The branching ratio then exceeds one, NREC1 and NREC2 grow
|
||||
C geometrically instead of draining, and the run overruns the recoil
|
||||
C arrays. Measured on tests/Si_1layer/Si_1layer_E19000.inp with
|
||||
C parIRL=1: NREC2 passes 2000 within a single projectile, whereas a
|
||||
C cutoff of 0.02 keeps the peak at 103. parERC defaults to 0.0 in the
|
||||
C GUI, so this combination is reachable without the user doing anything
|
||||
C unusual. Refuse it here, before any transport work, instead of
|
||||
C corrupting memory later on.
|
||||
if (irl.ge.1 .and. erc.le.0.d0) then
|
||||
write(*,*) 'ERROR : parIRL=1 requires a positive parERC cutoff.'
|
||||
write(*,*) ' parERC=0 makes the recoil cascade diverge'
|
||||
write(*,*) ' and overrun the recoil arrays. Set parERC to'
|
||||
write(*,*) ' a positive value (0.02 is a common choice).'
|
||||
STOP 1
|
||||
endif
|
||||
C=======================================================================
|
||||
C OPEN OUTPUT FILES AND START CLOCK
|
||||
C=======================================================================
|
||||
@@ -1401,11 +1425,18 @@ C
|
||||
MAXA=MAX0(MAXA,NREC2)
|
||||
NALL=NALL+NREC2
|
||||
NREC1=0
|
||||
C Guard against a cascade that does not fit the recoil lists. A
|
||||
C non-converging cascade reaches this test with NREC2 far above any array
|
||||
C bound, so the old fall-through into the processing loop below overran
|
||||
C every recoil array and killed the run with SIGSEGV. A cascade that does
|
||||
C not fit is not recoverable, so stop with a non-zero status rather than
|
||||
C let the CGI report a truncated run as a success.
|
||||
IF(NREC2.GT.2000) GO TO 8885
|
||||
GO TO 8886
|
||||
8885 WRITE(6,8887)
|
||||
8887 FORMAT(1X,'ERROR : DIMENSION IN THE RECOIL LOOP ',
|
||||
& 'MUST BE INCREASED')
|
||||
STOP 1
|
||||
8886 CONTINUE
|
||||
C
|
||||
C PROCESS THE PARTICLES IN LIST 2
|
||||
|
||||
@@ -264,7 +264,7 @@ function createWindow () {
|
||||
console.log(result.filePaths);
|
||||
if (!result.canceled) {
|
||||
setImmediate(function() {
|
||||
var focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
var focusedWindow = win;
|
||||
focusedWindow.webContents.send('openFile',result.filePaths);
|
||||
});
|
||||
}
|
||||
@@ -284,7 +284,7 @@ function createWindow () {
|
||||
).then(result => {
|
||||
setImmediate(function() {
|
||||
console.log(result.filePaths)
|
||||
var focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
var focusedWindow = win;
|
||||
focusedWindow.webContents.send('selectFolder',result.filePaths);
|
||||
});
|
||||
}).catch(err => {
|
||||
@@ -297,7 +297,7 @@ function createWindow () {
|
||||
accelerator: 'CmdOrCtrl+S',
|
||||
click () {
|
||||
setImmediate(function() {
|
||||
var focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
var focusedWindow = win;
|
||||
focusedWindow.webContents.send('saveFile','');
|
||||
});
|
||||
}
|
||||
@@ -319,7 +319,7 @@ function createWindow () {
|
||||
return;
|
||||
}
|
||||
setImmediate(function() {
|
||||
var focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
var focusedWindow = win;
|
||||
focusedWindow.webContents.send('saveFile',result.filePath);
|
||||
});
|
||||
}).catch(err => {
|
||||
@@ -332,7 +332,7 @@ function createWindow () {
|
||||
accelerator: 'CmdOrCtrl+P',
|
||||
click () {
|
||||
setImmediate(function() {
|
||||
var focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
var focusedWindow = win;
|
||||
const options = {};
|
||||
focusedWindow.webContents.print(options, (success, errorType) => {
|
||||
if (!success) console.log(errorType)
|
||||
@@ -367,7 +367,7 @@ function createWindow () {
|
||||
console.log(result.filePaths);
|
||||
if (!result.canceled) {
|
||||
setImmediate(function() {
|
||||
var focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
var focusedWindow = win;
|
||||
focusedWindow.webContents.send('plotProf',result.filePaths);
|
||||
});
|
||||
}
|
||||
@@ -392,7 +392,7 @@ function createWindow () {
|
||||
console.log(result.filePaths);
|
||||
if (!result.canceled) {
|
||||
setImmediate(function() {
|
||||
var focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
var focusedWindow = win;
|
||||
focusedWindow.webContents.send('plotFrac',result.filePaths);
|
||||
});
|
||||
}
|
||||
@@ -417,7 +417,7 @@ function createWindow () {
|
||||
console.log(result.filePaths);
|
||||
if (!result.canceled) {
|
||||
setImmediate(function() {
|
||||
var focusedWindow = BrowserWindow.getFocusedWindow();
|
||||
var focusedWindow = win;
|
||||
focusedWindow.webContents.send('plotMean',result.filePaths);
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TrimSP",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"description": "TRIMSP-NL Workbench for low-energy muon and ion implantation simulations.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -85,8 +85,16 @@ escaped_trimsp_binary="${escaped_trimsp_binary//|/\\|}"
|
||||
escaped_trimsp_binary="${escaped_trimsp_binary//\"/\\\"}"
|
||||
sed 's|^my \$trimsp_bin = .*|my $trimsp_bin = "'"$escaped_trimsp_binary"'";|' \
|
||||
"$project_root/singleTrimSP.cgi" > "$cgi_stage"
|
||||
perl -c "$cgi_stage" >/dev/null
|
||||
install -m 0755 "$cgi_stage" "$cgi_target"
|
||||
|
||||
# The installed CGI must embed the selected engine path exactly; a future
|
||||
# source layout change could silently break the sed substitution above.
|
||||
if ! grep -Fq 'my $trimsp_bin = "'"$trimsp_binary"'";' "$cgi_target"; then
|
||||
printf 'Deployed CGI does not embed the selected engine path %s\n' "$trimsp_binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The browser assets and the CGI must be readable (and, for the CGI,
|
||||
# executable) by the web server. On the live server the web space is owned by
|
||||
# the apache user; on a developer machine the files may instead be owned by
|
||||
|
||||
+370
-29
@@ -2,25 +2,280 @@
|
||||
#
|
||||
# Copyright Zaher Salman 2023-.
|
||||
# zaher.salman@psi.ch
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# singleTrimSP.cgi
|
||||
#
|
||||
# Web backend for TRIMSP-NL: accepts a run directory + file name with the
|
||||
# submitted file content, runs the transport engine, and on request produces a
|
||||
# downloadable results archive.
|
||||
#
|
||||
# Actions:
|
||||
# (none) POST fn + rngType + lineN... write the file, then run/archive
|
||||
# read GET fn stream a run-directory file
|
||||
# download GET run stream the results archive (.tgz)
|
||||
# health GET deployment self-check (JSON)
|
||||
#
|
||||
# The browser contract is deliberately unchanged from before the hardening:
|
||||
# JSON responses keep ok/phase/message/exitCode/archiveUrl/log;
|
||||
# read/download stream text/gzip with real HTTP status codes.
|
||||
#
|
||||
# Environment overrides (all optional; nothing here requires Apache config):
|
||||
# TRIMSP_BIN - path to the trimspNL engine (the deploy script rewrites
|
||||
# the $trimsp_bin literal below; env is a fallback)
|
||||
# TRIMSP_TMPROOT - run root (default /tmp). The browser builds paths as
|
||||
# <root>/<run>/<file>, so changing this requires the
|
||||
# matching client change; the default is safe.
|
||||
# TRIMSP_CMD_TIMEOUT - per-command watchdog seconds (default 300). Must stay
|
||||
# above the engine's own internal bounds.
|
||||
# TRIMSP_JOB_MAXAGE - stale run/archive lifetime seconds (default 86400).
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
use CGI;
|
||||
use File::Path qw(make_path remove_tree);
|
||||
use File::Temp qw(tempfile);
|
||||
use Fcntl qw(:DEFAULT :flock);
|
||||
use JSON::PP qw(encode_json);
|
||||
|
||||
# The web installer rewrites this path to the selected installed binary.
|
||||
my $trimsp_bin = "/usr/local/bin/trimspNL";
|
||||
my $workroot = "/tmp";
|
||||
|
||||
$trimsp_bin = $ENV{TRIMSP_BIN} if defined($ENV{TRIMSP_BIN}) && $ENV{TRIMSP_BIN} ne "";
|
||||
$workroot = $ENV{TRIMSP_TMPROOT} if defined($ENV{TRIMSP_TMPROOT}) && $ENV{TRIMSP_TMPROOT} ne "";
|
||||
|
||||
# Per-command watchdog. Every external command (trimspNL, tar) is bounded by an
|
||||
# alarm() watchdog in run_bounded_command and killed as a process group, so a
|
||||
# hung engine or tar cannot strand an Apache worker indefinitely.
|
||||
my $CMD_TIMEOUT = (defined($ENV{TRIMSP_CMD_TIMEOUT}) && $ENV{TRIMSP_CMD_TIMEOUT} =~ /^\d+$/)
|
||||
? int($ENV{TRIMSP_CMD_TIMEOUT}) : 300;
|
||||
|
||||
# Job/archive lifetime in seconds. prune_stale_jobs() removes trimsp run
|
||||
# directories and orphaned archives older than this, bounding disk usage from
|
||||
# long-running or abandoned browser sessions.
|
||||
my $JOB_MAXAGE = (defined($ENV{TRIMSP_JOB_MAXAGE}) && $ENV{TRIMSP_JOB_MAXAGE} =~ /^\d+$/)
|
||||
? int($ENV{TRIMSP_JOB_MAXAGE}) : 86400;
|
||||
|
||||
# Request-body caps. The browser posts one cfg, one .inp per scan point, and one
|
||||
# sequence file; these limits stay far above real decks while stopping a crafted
|
||||
# link from pushing unbounded content into the run directory.
|
||||
my $MAX_REQUEST_LINES = 20000;
|
||||
my $MAX_REQUEST_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
# Run name: alphanumerics, underscore, hyphen only (no dot, so ".", ".." can
|
||||
# never be a path component). A run-name class that admitted dots is exactly
|
||||
# the traversal hole muSRFlow carries; keep it dot-free. \z (not $) additionally
|
||||
# rejects a trailing newline from a %0A tail. The file component stays nested in
|
||||
# the same anchored pattern below, so no separate file-name regex is needed.
|
||||
my $run_name_re = qr{^[A-Za-z0-9_-]+\z};
|
||||
|
||||
# Derive the path allowlist from the configured root so a TRIMSP_TMPROOT
|
||||
# override stays consistent across every action.
|
||||
my $work_path_re = qr{^\Q$workroot\E/([A-Za-z0-9_-]+)/([A-Za-z0-9_.-]+)\z};
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# Emit an error as a real HTTP status code with a JSON body, then exit. The
|
||||
# payload carries the original ok/phase/message fields only, so error responses
|
||||
# keep exactly the same shape the browser already understands.
|
||||
sub json_error {
|
||||
my ($status, $payload) = @_;
|
||||
print "Status: $status\r\n";
|
||||
print "Content-Type: application/json; charset=UTF-8\r\n";
|
||||
print "Cache-Control: no-store\r\n\r\n";
|
||||
print encode_json($payload);
|
||||
exit 0;
|
||||
}
|
||||
|
||||
# Shell quoting for browser-derived command components. After the path checks
|
||||
# the tokens still go through shell_quote(); only server-side literals are ever
|
||||
# interpolated unquoted.
|
||||
sub shell_quote {
|
||||
my ($s) = @_;
|
||||
$s =~ s/'/'\\''/g;
|
||||
return "'" . $s . "'";
|
||||
}
|
||||
|
||||
# Run an arbitrary shell command with an alarm() watchdog, isolating the child
|
||||
# in its own process group so a timeout can kill the whole pipeline (including
|
||||
# grandchildren) without touching this CGI process. Returns system()'s waited
|
||||
# status encoding, or -1 (could not start) / -2 (watchdog fired).
|
||||
sub run_bounded_command {
|
||||
my ($command) = @_;
|
||||
my $pid = fork();
|
||||
return -1 if !defined($pid);
|
||||
if ($pid == 0) {
|
||||
setpgrp(0, 0);
|
||||
umask(022);
|
||||
open(STDIN, "<", "/dev/null") or _exit(127);
|
||||
exec { "/bin/sh" } "/bin/sh", "-c", $command;
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
my $timed_out = 0;
|
||||
local $SIG{ALRM} = sub {
|
||||
$timed_out = 1;
|
||||
kill "KILL", -$pid;
|
||||
};
|
||||
my $status = -1;
|
||||
eval {
|
||||
alarm($CMD_TIMEOUT);
|
||||
waitpid($pid, 0);
|
||||
alarm(0);
|
||||
$status = $?;
|
||||
};
|
||||
my $wait_error = $@;
|
||||
alarm(0);
|
||||
if ($timed_out) {
|
||||
# waitpid may be interrupted by SIGALRM or return after the killed child
|
||||
# is reaped. A second call is harmless in the latter case and avoids a
|
||||
# zombie in the former.
|
||||
waitpid($pid, 0);
|
||||
return -2;
|
||||
}
|
||||
if ($wait_error) {
|
||||
kill "KILL", -$pid;
|
||||
waitpid($pid, 0);
|
||||
return -1;
|
||||
}
|
||||
return $status;
|
||||
}
|
||||
|
||||
# Convert a run_bounded_command result into (exit_code, human description)
|
||||
# matching the vocabulary the browser shows in diagnostics.
|
||||
sub describe_result {
|
||||
my ($result) = @_;
|
||||
if ($result == -2) {
|
||||
return (124, "timed out after ${CMD_TIMEOUT}s");
|
||||
} elsif ($result == -1) {
|
||||
return (127, "failed to start: $!");
|
||||
} elsif ($result & 127) {
|
||||
return (128 + ($result & 127), "terminated by signal " . ($result & 127));
|
||||
}
|
||||
return ($result >> 8, "exit code " . ($result >> 8));
|
||||
}
|
||||
|
||||
# Accept or create the run directory. Refuses symlinks and non-directories so a
|
||||
# pre-created /tmp/<run> link or planted file cannot redirect our writes, and
|
||||
# uses mode 0700 so results are not world-readable in the shared temp root.
|
||||
sub ensure_safe_work_dir {
|
||||
my ($work_path) = @_;
|
||||
if (-l $work_path || (-e $work_path && !-d $work_path)) {
|
||||
return (0, "job directory is not a plain directory");
|
||||
}
|
||||
if (!-d $work_path) {
|
||||
if (!-d $workroot) {
|
||||
return (0, "run root does not exist");
|
||||
}
|
||||
my $ok = eval {
|
||||
make_path($work_path, { mode => 0700, error => \my $err });
|
||||
!@{$err};
|
||||
};
|
||||
if (!$ok) {
|
||||
return (0, "could not create job directory");
|
||||
}
|
||||
}
|
||||
return (1, "");
|
||||
}
|
||||
|
||||
# Occupy the per-run archive lock. The archive step mutates the whole run
|
||||
# directory (tar + remove_tree), so it is serialized; the per-point engine runs
|
||||
# are intentionally concurrent and are not locked. The kernel releases flock
|
||||
# when the CGI exits, so a dying worker cannot strand the lock.
|
||||
sub lock_archive {
|
||||
my ($work_path) = @_;
|
||||
my $lock_path = "$work_path/.trimsp-lock";
|
||||
my $lfh;
|
||||
if (!sysopen($lfh, $lock_path, O_RDWR | O_CREAT, 0600)) {
|
||||
return undef;
|
||||
}
|
||||
if (!flock($lfh, LOCK_EX | LOCK_NB)) {
|
||||
close($lfh);
|
||||
return undef;
|
||||
}
|
||||
return $lfh;
|
||||
}
|
||||
|
||||
# Age out abandoned trimsp jobs. Mirrors muSRFlow's pruner but stays safe in
|
||||
# the shared /tmp root: only directories that match the run-name rule AND
|
||||
# contain a TrimSP.cfg marker are candidates, plus orphaned <run>.tgz archives
|
||||
# whose run directory is already gone, plus leaked per-request log files.
|
||||
# Symlinks are skipped and remove_tree runs in safe mode, so a crafted entry is
|
||||
# never followed or deleted.
|
||||
sub prune_stale_jobs {
|
||||
return 0 if !-d $workroot;
|
||||
my $now = time();
|
||||
my $removed = 0;
|
||||
|
||||
opendir(my $dh, $workroot) or return 0;
|
||||
while (my $entry = readdir($dh)) {
|
||||
next if $entry eq "." || $entry eq "..";
|
||||
next if $entry !~ /^[A-Za-z0-9_-]{5,}$/;
|
||||
my $candidate = "$workroot/$entry";
|
||||
next if -l $candidate || !-d $candidate;
|
||||
# Only ever touch directories this backend creates.
|
||||
next if !-f "$candidate/TrimSP.cfg";
|
||||
my @stats = stat($candidate);
|
||||
next if @stats < 9;
|
||||
next if $stats[9] > $now - $JOB_MAXAGE;
|
||||
# Never delete a directory that is currently being archived.
|
||||
my $lock = "$candidate/.trimsp-lock";
|
||||
if (-e $lock) {
|
||||
sysopen(my $lfh, $lock, O_RDWR | O_CREAT, 0600) or next;
|
||||
if (!flock($lfh, LOCK_EX | LOCK_NB)) { close($lfh); next; }
|
||||
close($lfh);
|
||||
}
|
||||
remove_tree($candidate, { safe => 1, error => \my $err });
|
||||
$removed++ if !@{$err};
|
||||
}
|
||||
closedir($dh);
|
||||
|
||||
# Orphaned archives: /tmp/<run>.tgz whose run directory is already gone.
|
||||
opendir($dh, $workroot) or return $removed;
|
||||
while (my $entry = readdir($dh)) {
|
||||
next if $entry !~ /^([A-Za-z0-9_-]{5,})\.tgz\z/;
|
||||
next if -d "$workroot/$1";
|
||||
my $path = "$workroot/$entry";
|
||||
next if -l $path || !-f $path;
|
||||
my @stats = stat($path);
|
||||
next if @stats < 9 || $stats[9] > $now - $JOB_MAXAGE;
|
||||
unlink($path);
|
||||
$removed++;
|
||||
}
|
||||
closedir($dh);
|
||||
|
||||
# Leaked per-request logs from requests that died before unlink().
|
||||
opendir($dh, $workroot) or return $removed;
|
||||
while (my $entry = readdir($dh)) {
|
||||
next if $entry !~ /^trimsp-([A-Za-z0-9_-]{5,})-[A-Za-z0-9_-]{4,}\z/;
|
||||
my $path = "$workroot/$entry";
|
||||
next if -l $path || !-f $path;
|
||||
my @stats = stat($path);
|
||||
next if @stats < 9 || $stats[9] > $now - $JOB_MAXAGE;
|
||||
unlink($path);
|
||||
$removed++;
|
||||
}
|
||||
closedir($dh);
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Request dispatch
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
my $in = CGI->new;
|
||||
my $action = $in->param("action") // "run";
|
||||
|
||||
# Opportunistically age out stale jobs before serving or mutating anything.
|
||||
prune_stale_jobs();
|
||||
|
||||
if ($action eq "read") {
|
||||
my $read_filename = $in->param("fn") // "";
|
||||
if ($read_filename !~ m{^/tmp/[A-Za-z0-9_-]+/[A-Za-z0-9_.-]+$}
|
||||
|| !-f $read_filename) {
|
||||
if ($read_filename !~ $work_path_re || !-f $read_filename || -l $read_filename) {
|
||||
print "Status: 404 Not Found\r\n";
|
||||
print "Content-Type: text/plain; charset=UTF-8\r\n\r\n";
|
||||
print "Requested simulation output was not found.\n";
|
||||
@@ -44,7 +299,7 @@ if ($action eq "read") {
|
||||
if ($action eq "download") {
|
||||
my $download_run = $in->param("run") // "";
|
||||
my $archive_path = "$workroot/$download_run.tgz";
|
||||
if ($download_run !~ /^[A-Za-z0-9_-]+$/ || !-f $archive_path) {
|
||||
if ($download_run !~ $run_name_re || !-f $archive_path || -l $archive_path) {
|
||||
print "Status: 404 Not Found\r\n";
|
||||
print "Content-Type: text/plain; charset=UTF-8\r\n\r\n";
|
||||
print "Requested results archive was not found.\n";
|
||||
@@ -69,20 +324,48 @@ if ($action eq "download") {
|
||||
exit 0;
|
||||
}
|
||||
|
||||
if ($action eq "health") {
|
||||
my %probe;
|
||||
$probe{engine} = {
|
||||
exists => (-e $trimsp_bin) ? JSON::PP::true : JSON::PP::false,
|
||||
executable => (-x $trimsp_bin) ? JSON::PP::true : JSON::PP::false,
|
||||
size => (-f $trimsp_bin && !-l $trimsp_bin) ? (-s $trimsp_bin || 0) : 0,
|
||||
};
|
||||
$probe{runRoot} = {
|
||||
exists => (-d $workroot) ? JSON::PP::true : JSON::PP::false,
|
||||
writable => (-w $workroot) ? JSON::PP::true : JSON::PP::false,
|
||||
};
|
||||
$probe{settings} = {
|
||||
commandTimeoutSeconds => $CMD_TIMEOUT,
|
||||
jobMaxAgeSeconds => $JOB_MAXAGE,
|
||||
maxRequestBytes => $MAX_REQUEST_BYTES,
|
||||
maxRequestLines => $MAX_REQUEST_LINES,
|
||||
};
|
||||
print "Content-Type: application/json\r\n";
|
||||
print "Cache-Control: no-store\r\n\r\n";
|
||||
print encode_json({
|
||||
ok => JSON::PP::true,
|
||||
action => "health",
|
||||
probe => \%probe,
|
||||
});
|
||||
exit 0;
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write + run path (default action). The browser posts fn + rngType + lineN...
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
my $requested_filename = $in->param("fn") // "";
|
||||
my $rng_type = $in->param("rngType") // "pcg32";
|
||||
|
||||
# Accept only the private run path produced by TrimSPweb.js. Restricting both
|
||||
# path components prevents CGI parameters from selecting arbitrary files.
|
||||
if ($requested_filename !~ m{^/tmp/([A-Za-z0-9_-]+)/([A-Za-z0-9_.-]+)$}) {
|
||||
print "Status: 400 Bad Request\r\n";
|
||||
print "Content-Type: application/json\r\n\r\n";
|
||||
print encode_json({
|
||||
if ($requested_filename !~ $work_path_re) {
|
||||
json_error(400, {
|
||||
ok => JSON::PP::false,
|
||||
phase => "request",
|
||||
message => "Invalid simulation filename",
|
||||
});
|
||||
exit 0;
|
||||
}
|
||||
|
||||
my ($run_name, $file_name) = ($1, $2);
|
||||
@@ -98,18 +381,36 @@ $run_base =~ s/\.inp$//;
|
||||
my @line_parameters = sort {
|
||||
($a =~ /line(\d+)/)[0] <=> ($b =~ /line(\d+)/)[0]
|
||||
} grep { /^line\d+$/ } $in->param;
|
||||
if (@line_parameters > $MAX_REQUEST_LINES) {
|
||||
json_error(413, {
|
||||
ok => JSON::PP::false,
|
||||
phase => "request",
|
||||
message => "Too many submitted lines",
|
||||
});
|
||||
}
|
||||
my $content = join("\n", map { scalar($in->param($_) // "") } @line_parameters) . "\n";
|
||||
if (length($content) > $MAX_REQUEST_BYTES) {
|
||||
json_error(413, {
|
||||
ok => JSON::PP::false,
|
||||
phase => "request",
|
||||
message => "Submitted file content is too large",
|
||||
});
|
||||
}
|
||||
|
||||
make_path($work_path, { mode => 0755 }) unless -d $work_path;
|
||||
my ($dir_ok, $dir_message) = ensure_safe_work_dir($work_path);
|
||||
if (!$dir_ok) {
|
||||
json_error(500, {
|
||||
ok => JSON::PP::false,
|
||||
phase => "request",
|
||||
message => $dir_message,
|
||||
});
|
||||
}
|
||||
open(my $output_file, ">", $filename) or do {
|
||||
print "Status: 500 Internal Server Error\r\n";
|
||||
print "Content-Type: application/json\r\n\r\n";
|
||||
print encode_json({
|
||||
json_error(500, {
|
||||
ok => JSON::PP::false,
|
||||
phase => "write",
|
||||
message => "Could not write the submitted simulation file",
|
||||
});
|
||||
exit 0;
|
||||
};
|
||||
print {$output_file} $content;
|
||||
close($output_file);
|
||||
@@ -122,6 +423,16 @@ my $log_content = "";
|
||||
my $request_logfile;
|
||||
my $ok = JSON::PP::true;
|
||||
|
||||
# Append diagnostics to the private request log before and after a command so
|
||||
# the JSON log field explains timeouts/signals instead of a bare exit code.
|
||||
sub append_log {
|
||||
my ($logfile, $text) = @_;
|
||||
return unless $logfile;
|
||||
return unless open(my $lfh, ">>", $logfile);
|
||||
print {$lfh} $text;
|
||||
close($lfh);
|
||||
}
|
||||
|
||||
if ($filename =~ /_Seq_Results\.dat$/) {
|
||||
$phase = "archive";
|
||||
unlink("$work_path/edist");
|
||||
@@ -132,16 +443,41 @@ if ($filename =~ /_Seq_Results\.dat$/) {
|
||||
my ($log_handle, $logfile) = tempfile("trimsp-$run_name-XXXXXX", DIR => $workroot, UNLINK => 0);
|
||||
close($log_handle);
|
||||
$request_logfile = $logfile;
|
||||
my $command = "tar -czf '$archive_path' -C '$work_path' . > '$logfile' 2>&1";
|
||||
system($command);
|
||||
$exit_code = $? == -1 ? 127 : $? >> 8;
|
||||
if ($exit_code == 0) {
|
||||
$archive_url = "/cgi-bin/singleTrimSP.cgi?action=download&run=$run_name";
|
||||
$message = "Created results archive";
|
||||
remove_tree($work_path);
|
||||
} else {
|
||||
|
||||
my $lock_fh = lock_archive($work_path);
|
||||
if (!$lock_fh) {
|
||||
$ok = JSON::PP::false;
|
||||
$message = "Could not create results archive";
|
||||
$message = "Another archive operation is active in this run";
|
||||
} else {
|
||||
# Replace a previous archive defensively, refusing a symlink target.
|
||||
if (-e $archive_path || -l $archive_path) {
|
||||
if (-l $archive_path || !-f $archive_path || !unlink($archive_path)) {
|
||||
$ok = JSON::PP::false;
|
||||
$message = "Could not replace the previous results archive";
|
||||
}
|
||||
}
|
||||
if ($ok) {
|
||||
my $command = "tar -czf " . shell_quote($archive_path)
|
||||
. " -C " . shell_quote($work_path)
|
||||
. " --exclude=./.trimsp-lock . "
|
||||
. ">> " . shell_quote($logfile) . " 2>&1";
|
||||
append_log($logfile, "\n[archive] \$ $command\n");
|
||||
my $result = run_bounded_command($command);
|
||||
($exit_code, my $completion) = describe_result($result);
|
||||
append_log($logfile, "[${completion}]\n");
|
||||
if ($exit_code == 0 && -s $archive_path) {
|
||||
$archive_url = "/cgi-bin/singleTrimSP.cgi?action=download&run=$run_name";
|
||||
$message = "Created results archive";
|
||||
remove_tree($work_path);
|
||||
} else {
|
||||
# A run that produced correct physics must not be reported as a
|
||||
# failure just because archiving failed.
|
||||
$ok = JSON::PP::false;
|
||||
$message = "Could not create results archive";
|
||||
unlink($archive_path) if -f $archive_path && !-l $archive_path;
|
||||
}
|
||||
}
|
||||
close($lock_fh);
|
||||
}
|
||||
} elsif ($filename !~ /TrimSP\.cfg$/) {
|
||||
$phase = "simulation";
|
||||
@@ -151,14 +487,18 @@ if ($filename =~ /_Seq_Results\.dat$/) {
|
||||
|
||||
# All user-derived command components were restricted above. Each request
|
||||
# receives a private log file so concurrent users never share output.
|
||||
my $command = "cd '$work_path' && timeout 300 '$trimsp_bin' "
|
||||
. "'$run_base' '$rng_type' >> '$logfile' 2>&1";
|
||||
system($command);
|
||||
$exit_code = $? == -1 ? 127 : $? >> 8;
|
||||
my $command = "cd " . shell_quote($work_path) . " && "
|
||||
. shell_quote($trimsp_bin) . " "
|
||||
. shell_quote($run_base) . " "
|
||||
. shell_quote($rng_type) . " >> " . shell_quote($logfile) . " 2>&1";
|
||||
append_log($logfile, "\n[simulation] \$ $command\n");
|
||||
my $result = run_bounded_command($command);
|
||||
($exit_code, my $completion) = describe_result($result);
|
||||
append_log($logfile, "[${completion}]\n");
|
||||
$ok = JSON::PP::false if $exit_code != 0;
|
||||
$message = $exit_code == 0
|
||||
? "TRIM.SP-NL completed $run_base"
|
||||
: "TRIM.SP-NL failed while running $run_base";
|
||||
: "TRIM.SP-NL failed while running $run_base ($completion)";
|
||||
}
|
||||
|
||||
# Return at most the latest 64 KiB to the browser and immediately remove the
|
||||
@@ -172,7 +512,8 @@ if ($request_logfile && -f $request_logfile && open(my $log_handle, "<", $reques
|
||||
unlink($request_logfile);
|
||||
}
|
||||
|
||||
print "Content-Type: application/json\r\n\r\n";
|
||||
print "Content-Type: application/json; charset=UTF-8\r\n";
|
||||
print "Cache-Control: no-store\r\n\r\n";
|
||||
print encode_json({
|
||||
ok => $ok,
|
||||
phase => $phase,
|
||||
@@ -182,4 +523,4 @@ print encode_json({
|
||||
log => $log_content,
|
||||
});
|
||||
|
||||
exit 0;
|
||||
exit 0;
|
||||
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# CGI hardening tests for singleTrimSP.cgi.
|
||||
#
|
||||
# Drives the real script directly (no web server) using REQUEST_METHOD /
|
||||
# CONTENT_LENGTH / QUERY_STRING env vars, exactly like a CGI runtime would.
|
||||
# A fake trimspNL engine provides success/fail/hang behavior so the watchdog,
|
||||
# logging, and archive paths can be exercised deterministically.
|
||||
#
|
||||
# Usage: tests/cgi-tests.sh
|
||||
# Exits non-zero on the first failing test.
|
||||
|
||||
set -u
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CGI="$HERE/../singleTrimSP.cgi"
|
||||
ROOT_REPO="$(cd "$HERE/.." && pwd)"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
ROOT="$TMP/root" # TRIMSP_TMPROOT for every request
|
||||
mkdir -p "$ROOT"
|
||||
|
||||
BIN="$TMP/bin/trimspNL" # fake engine
|
||||
mkdir -p "$TMP/bin"
|
||||
|
||||
cat > "$BIN" <<'EOF'
|
||||
#!/bin/sh
|
||||
case "${FAKE_MODE:-ok}" in
|
||||
fail)
|
||||
echo "fake engine failed as requested" 1>&2
|
||||
exit 3
|
||||
;;
|
||||
hang)
|
||||
echo "$$" > "$FAKE_PIDFILE"
|
||||
sleep 120
|
||||
exit 0
|
||||
;;
|
||||
ok)
|
||||
base="$1"
|
||||
printf '# col1 col2\n1 5\n2 10\n' > "$base.rge"
|
||||
printf 'SEQ %s line1\nSEQ %s line2\n' "$base" "$base" > "$base.seq"
|
||||
echo "fake engine completed $base"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$BIN"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
note() { printf '\n== %s\n' "$*"; }
|
||||
ok() { PASS=$((PASS+1)); printf ' PASS: %s\n' "$*"; }
|
||||
bad() { FAIL=$((FAIL+1)); printf ' FAIL: %s\n' "$*"; }
|
||||
|
||||
# run_request METHOD QUERY_STRING BODY_FILE -> sets RUN_HEADERS, RUN_BODY, RUN_STATUS
|
||||
run_request() {
|
||||
local method="$1" query="$2" body_file="$3"
|
||||
local in_redir="/dev/null"
|
||||
local BODY_LEN=0
|
||||
if [ "$method" = "POST" ] && [ -n "$body_file" ]; then
|
||||
BODY_LEN="$(wc -c < "$body_file")"
|
||||
in_redir="$body_file"
|
||||
fi
|
||||
local out
|
||||
out="$(REQUEST_METHOD="$method" \
|
||||
CONTENT_TYPE="application/x-www-form-urlencoded" \
|
||||
CONTENT_LENGTH="$BODY_LEN" \
|
||||
QUERY_STRING="$query" \
|
||||
TRIMSP_TMPROOT="$ROOT" \
|
||||
TRIMSP_BIN="$BIN" \
|
||||
TRIMSP_CMD_TIMEOUT="4" \
|
||||
TRIMSP_JOB_MAXAGE="86400" \
|
||||
/usr/bin/perl -X "$CGI" < "$in_redir" 2>&1)"
|
||||
# Split header block from body.
|
||||
RUN_HEADERS="$(printf '%s\n' "$out" | sed -n '1,/^\r*$/p' | head -n -1)"
|
||||
RUN_BODY="$(printf '%s\n' "$out" | sed -n '/^\r*$/,$p' | tail -n +1)"
|
||||
# Without a status line the response is implicitly 200.
|
||||
if printf '%s' "$RUN_HEADERS" | grep -q '^Status:'; then
|
||||
RUN_STATUS="$(printf '%s' "$RUN_HEADERS" | sed -n 's/^Status: *\([0-9][0-9][0-9]\).*/\1/p' | head -1)"
|
||||
else
|
||||
RUN_STATUS="200"
|
||||
fi
|
||||
}
|
||||
|
||||
body_contains() { printf '%s' "$RUN_BODY" | grep -q "$1"; }
|
||||
|
||||
# Percent-encode a value the way the browser's URLSearchParams does.
|
||||
urlencode() {
|
||||
printf '%s' "$1" | perl -pe 's/([^A-Za-z0-9._~-])/sprintf("%%%02X", ord($1))/ge'
|
||||
}
|
||||
|
||||
# The CGI derives its path allowlist from TRIMSP_TMPROOT, so test bodies must
|
||||
# reference the configured root (default /tmp in production).
|
||||
FN_ROOT="$ROOT"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
note "health"
|
||||
run_request GET "action=health" ""
|
||||
if body_contains '"ok":true' && body_contains '"commandTimeoutSeconds"'; then
|
||||
ok "health reports ok + settings"
|
||||
else
|
||||
bad "health response: $RUN_BODY"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
note "path traversal is rejected"
|
||||
TRAV="$TMP/trav.txt"
|
||||
printf 'action=run&fn=%s&line0=x\n' "$(urlencode "$FN_ROOT/../../escape/x.inp")" > "$TRAV"
|
||||
run_request POST "" "$TRAV"
|
||||
if [ "$RUN_STATUS" = "400" ] && body_contains 'Invalid simulation filename'; then
|
||||
ok "fn contains /../ component -> 400"
|
||||
else
|
||||
bad "traversal not rejected (status=$RUN_STATUS body=$RUN_BODY)"
|
||||
fi
|
||||
|
||||
printf 'action=run&fn=%s&line0=x\n' "$(urlencode "$FN_ROOT/../x.inp")" > "$TRAV"
|
||||
run_request POST "" "$TRAV"
|
||||
if [ "$RUN_STATUS" = "400" ]; then
|
||||
ok "single-level /../ in run component -> 400"
|
||||
else
|
||||
bad "single-level traversal not rejected (status=$RUN_STATUS body=$RUN_BODY)"
|
||||
fi
|
||||
|
||||
printf 'action=run&fn=%s&line0=x\n' "$(urlencode "$FN_ROOT/run12345/x.inp")%0A" > "$TRAV"
|
||||
run_request POST "" "$TRAV"
|
||||
if [ "$RUN_STATUS" = "400" ]; then
|
||||
ok "trailing newline (%0A) in fn rejected"
|
||||
else
|
||||
bad "%0A tail not rejected (status=$RUN_STATUS body=$RUN_BODY)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
note "write/read round-trip with special characters"
|
||||
RUN=round1234
|
||||
CFG="$TMP/cfgbody"
|
||||
printf 'fn=%s&line0=%s&line1=%s\n' \
|
||||
"$(urlencode "$FN_ROOT/$RUN/TrimSP.cfg")" \
|
||||
"ProjectileParameters" \
|
||||
"$(urlencode 'val=100¬e=semi+colon%,#hash')" \
|
||||
> "$CFG"
|
||||
run_request POST "" "$CFG"
|
||||
if [ "$RUN_STATUS" = "200" ] && body_contains '"phase":"save"'; then
|
||||
ok "TrimSP.cfg written (phase=save)"
|
||||
else
|
||||
bad "cfg write failed (status=$RUN_STATUS body=$RUN_BODY)"
|
||||
fi
|
||||
|
||||
run_request GET "action=read&fn=$(urlencode "$FN_ROOT/$RUN/TrimSP.cfg")" ""
|
||||
if body_contains 'val=100¬e=semi+colon%,#hash'; then
|
||||
ok "read-back matches decoded content exactly (escaped +,%,&,#,=)"
|
||||
else
|
||||
bad "read-back mismatch: $RUN_BODY"
|
||||
fi
|
||||
|
||||
# Invalid read path (symlink-leading component)
|
||||
ln -s /etc "$ROOT/$RUN/linkdir" 2>/dev/null || true
|
||||
run_request GET "action=read&fn=$(urlencode "$FN_ROOT/$RUN/linkdir")" ""
|
||||
if [ "$RUN_STATUS" = "404" ]; then
|
||||
ok "read of non-regular file -> 404"
|
||||
else
|
||||
bad "read of symlink not rejected (status=$RUN_STATUS)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
note "engine success"
|
||||
run_request POST "" "$TMP/cfgbody" # ensure run dir exists
|
||||
INP="$TMP/inpbody"
|
||||
printf 'fn=%s&rngType=pcg32&line0=X\n' "$(urlencode "$FN_ROOT/$RUN/${RUN}_E100.inp")" > "$INP"
|
||||
FAKE_MODE=ok run_request POST "" "$INP"
|
||||
if body_contains '"exitCode":0' && body_contains '"ok":true'; then
|
||||
ok "engine completed OK"
|
||||
else
|
||||
bad "engine success not reported (body=$RUN_BODY)"
|
||||
fi
|
||||
|
||||
# The .seq file the fake engine wrote must be readable.
|
||||
run_request GET "action=read&fn=$(urlencode "$FN_ROOT/$RUN/${RUN}_E100.seq")" ""
|
||||
if body_contains 'SEQ'; then
|
||||
ok "engine output .seq readable"
|
||||
else
|
||||
bad "engine .seq not readable (status=$RUN_STATUS body=$RUN_BODY)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
note "engine failure is reported, not silently ignored"
|
||||
FAILINP="$TMP/failbody"
|
||||
printf 'fn=%s&rngType=pcg32&line0=X\n' "$(urlencode "$FN_ROOT/$RUN/${RUN}_E200.inp")" > "$FAILINP"
|
||||
FAKE_MODE=fail run_request POST "" "$FAILINP"
|
||||
if body_contains '"exitCode":3' && body_contains '"ok":false'; then
|
||||
ok "engine failure -> exitCode 3, ok=false"
|
||||
else
|
||||
bad "engine failure misreported (body=$RUN_BODY)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
note "watchdog kills a hung engine (alarm + process-group kill)"
|
||||
HANGINP="$TMP/hangbody"
|
||||
printf 'fn=%s&rngType=pcg32&line0=X\n' "$(urlencode "$FN_ROOT/$RUN/${RUN}_E300.inp")" > "$HANGINP"
|
||||
HANG_PIDF="$TMP/pid.txt"
|
||||
FAKE_MODE=hang FAKE_PIDFILE="$HANG_PIDF" TRIMSP_CMD_TIMEOUT="2" run_request POST "" "$HANGINP"
|
||||
if ! [ -e "$HANG_PIDF" ]; then
|
||||
bad "hang engine never recorded a pid (test setup broken)"
|
||||
else
|
||||
DEAD="$(kill -0 "$(cat "$HANG_PIDF")" 2>/dev/null && echo no || echo yes)"
|
||||
if body_contains '"exitCode":124' && [ "$DEAD" = "yes" ]; then
|
||||
ok "hung engine timed out -> exitCode 124, process group reaped"
|
||||
else
|
||||
bad "watchdog did not fire correctly (exit=$RUN_BODY dead=$DEAD)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
note "archive + download"
|
||||
# The sequence file triggers the archive of the whole run directory.
|
||||
SEQ="$TMP/seqbody"
|
||||
printf 'fn=%s&line0=S\n' "$(urlencode "$FN_ROOT/$RUN/${RUN}_Seq_Results.dat")" > "$SEQ"
|
||||
run_request POST "" "$SEQ"
|
||||
if body_contains '"archiveUrl"' && body_contains '"phase":"archive"' && [ ! -d "$ROOT/$RUN" ]; then
|
||||
ok "archive created and run directory removed"
|
||||
else
|
||||
bad "archive flow broken (body=$RUN_BODY dir_gone=$( [ ! -d "$ROOT/$RUN" ] && echo yes || echo no))"
|
||||
fi
|
||||
|
||||
# Binary download: run through a file so NUL bytes survive.
|
||||
REQ="$TMP/dlreq"
|
||||
{
|
||||
printf 'action=download&run=%s' "$RUN"
|
||||
printf '\n'
|
||||
} > "$REQ"
|
||||
DL="$TMP/dl.out"
|
||||
REQUEST_METHOD=GET CONTENT_TYPE="application/x-www-form-urlencoded" \
|
||||
CONTENT_LENGTH=0 QUERY_STRING="action=download&run=$RUN" \
|
||||
TRIMSP_TMPROOT="$ROOT" TRIMSP_BIN="$BIN" \
|
||||
TRIMSP_CMD_TIMEOUT="4" TRIMSP_JOB_MAXAGE="86400" \
|
||||
/usr/bin/perl -X "$CGI" < /dev/null > "$DL" 2>&1
|
||||
if grep -a '^Status: 404' "$DL" >/dev/null; then
|
||||
bad "download 404 for archived run"
|
||||
elif grep -a '^Status:' "$DL" >/dev/null; then
|
||||
bad "download unexpected error ($(grep -a '^Status:' "$DL" | head -1))"
|
||||
else
|
||||
DL_BODY="$TMP/dl.body"
|
||||
perl -0777 -e '
|
||||
local $/; my $raw = <>;
|
||||
my ($body) = $raw =~ /^.*?\r?\n\r?\n(.*)\z/s;
|
||||
print STDOUT $body' "$DL" > "$DL_BODY"
|
||||
if [ "$(dd if="$DL_BODY" bs=1 count=2 2>/dev/null | od -An -tx1 | tr -d ' \n')" = "1f8b" ]; then
|
||||
ok "download returns a gzip stream"
|
||||
else
|
||||
bad "download did not return gzip (hex=$(dd if="$DL_BODY" bs=1 count=8 2>/dev/null | od -An -tx1 | tr -d ' \n'))"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
note "pruner removes stale run dirs and keeps fresh ones"
|
||||
STALE=stale0001
|
||||
FRESH=fresh0001
|
||||
mkdir -p "$ROOT/$STALE" "$ROOT/$FRESH"
|
||||
printf 'cfg\n' > "$ROOT/$STALE/TrimSP.cfg"
|
||||
printf 'x\n' > "$ROOT/$STALE/extra"
|
||||
printf 'cfg\n' > "$ROOT/$FRESH/TrimSP.cfg"
|
||||
# Backdate AFTER populating so the mtime that matters (dir mtime) is stale.
|
||||
touch -d "2000-01-01 00:00:00" "$ROOT/$STALE"
|
||||
run_request GET "action=health" ""
|
||||
if [ ! -d "$ROOT/$STALE" ] && [ -d "$ROOT/$FRESH" ]; then
|
||||
ok "pruner removed stale run dir, kept fresh one"
|
||||
else
|
||||
bad "pruner misbehaved (stale_gone=$( [ ! -d "$ROOT/$STALE" ] && echo yes || echo no) fresh_kept=$( [ -d "$ROOT/$FRESH" ] && echo yes || echo no))"
|
||||
fi
|
||||
|
||||
# Orphaned archive pruned.
|
||||
touch -d "2000-01-01 00:00:00" "$ROOT/orphan1234.tgz"
|
||||
run_request GET "action=health" ""
|
||||
if [ ! -e "$ROOT/orphan1234.tgz" ]; then
|
||||
ok "pruner removed orphaned archive"
|
||||
else
|
||||
bad "orphaned archive not pruned"
|
||||
fi
|
||||
|
||||
# Non-trimsp directory in the shared root is never touched.
|
||||
OTHER="$ROOT/other-user-000"
|
||||
mkdir -p "$OTHER"
|
||||
touch -d "2000-01-01 00:00:00" "$OTHER"
|
||||
run_request GET "action=health" ""
|
||||
if [ -d "$OTHER" ]; then
|
||||
ok "unrelated directory left untouched"
|
||||
else
|
||||
bad "pruner removed unrelated directory"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
note "symlinked run directory is refused"
|
||||
mkdir -p "$TMP/real_dir"
|
||||
ln -s "$TMP/real_dir" "$ROOT/symlnk001"
|
||||
SYM="$TMP/symbody"
|
||||
printf 'fn=%s&line0=x\n' "$(urlencode "$FN_ROOT/symlnk001/TrimSP.cfg")" > "$SYM"
|
||||
run_request POST "" "$SYM"
|
||||
if [ "$RUN_STATUS" = "500" ] && body_contains 'plain directory'; then
|
||||
ok "symlinked run directory refused"
|
||||
else
|
||||
bad "symlinked run dir accepted (status=$RUN_STATUS body=$RUN_BODY)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
note "request size cap"
|
||||
# The real client joins submitted lines on one body line with '&' separators.
|
||||
BIG="$TMP/bigbody"
|
||||
{
|
||||
printf 'fn=%s&rngType=pcg32&line0=z' "$(urlencode "$FN_ROOT/bigrun001/x.inp")"
|
||||
for i in $(seq 1 20001); do printf '&line%d=z' "$i"; done
|
||||
printf '\n'
|
||||
} > "$BIG"
|
||||
run_request POST "" "$BIG"
|
||||
if [ "$RUN_STATUS" = "413" ]; then
|
||||
ok "oversized request -> 413"
|
||||
else
|
||||
bad "oversized request not capped (status=$RUN_STATUS body=$RUN_BODY)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,108 @@
|
||||
// Checks the GUI handling of the parIRL/parERC combination that used to crash
|
||||
// the engine. A zero recoil cutoff makes the transport cascade diverge, so the
|
||||
// GUI supplies a cutoff when recoils are switched on and refuses the run if the
|
||||
// user insists on the zero cutoff.
|
||||
//
|
||||
// The real functions are lifted out of TrimSPlib.js and run against a minimal
|
||||
// document stub, so this exercises shipped code rather than a copy of it.
|
||||
// Run with: node tests/recoil-cutoff-test.js
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const libPath = path.join(__dirname, '..', 'TrimSPlib.js');
|
||||
const src = fs.readFileSync(libPath, 'utf8');
|
||||
|
||||
function grab(re) {
|
||||
const m = src.match(re);
|
||||
if (!m) {
|
||||
throw new Error('pattern not found in ' + libPath + ': ' + re);
|
||||
}
|
||||
return m[0];
|
||||
}
|
||||
|
||||
const fields = {};
|
||||
let shown = [];
|
||||
let statusMsg = null;
|
||||
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
if (!(id in fields)) {
|
||||
fields[id] = { value: '', focus() { this.focused = true; } };
|
||||
}
|
||||
return fields[id];
|
||||
}
|
||||
};
|
||||
global.showTransientWarning = (message) => { shown.push(message); };
|
||||
global.updateStatusBar = (options) => {
|
||||
if (options && options.run) { statusMsg = options.run; }
|
||||
};
|
||||
|
||||
eval([
|
||||
grab(/var TRIMSP_MIN_RECOIL_CUTOFF = [\d.]+;/),
|
||||
grab(/function recoilCutoffIsUnusable\(\) \{[\s\S]*?\n\}/),
|
||||
grab(/function handleRecoilModeChange\(\) \{[\s\S]*?\n\}/),
|
||||
].join('\n\n'));
|
||||
|
||||
// The pre-run refusal, lifted verbatim out of runSimulationSequence. It keeps
|
||||
// that function's return convention, where 0 means "aborted before the engine
|
||||
// was invoked" and 1 means the run may proceed.
|
||||
eval('function runGuardVerdict() {\n'
|
||||
+ grab(/ if \(recoilCutoffIsUnusable\(\)\) \{[\s\S]*?\n \}\n/)
|
||||
+ ' return 1;\n}\n');
|
||||
|
||||
function reset(irl, erc) {
|
||||
for (const key of Object.keys(fields)) { delete fields[key]; }
|
||||
const makeField = (value) => ({
|
||||
value: value,
|
||||
focus() { this.focused = true; },
|
||||
});
|
||||
fields.parIRL = makeField(String(irl));
|
||||
fields.parERC = makeField(String(erc));
|
||||
shown = [];
|
||||
statusMsg = null;
|
||||
}
|
||||
|
||||
const cases = [
|
||||
// description, IRL, ERC, touchIRL, fills, refuses
|
||||
['default GUI state', 0, '0.0', false, false, false],
|
||||
['parIRL 0->1, parERC=0 default', 1, '0.0', true, true, false],
|
||||
['user insists, edits parERC=0', 1, '0', false, false, true],
|
||||
['user sets parERC back to 0', 1, '0.0', false, false, true],
|
||||
['valid parIRL=1 with cutoff', 1, '0.02', false, false, false],
|
||||
['parIRL 0->1, cutoff already set', 1, '0.02', true, false, false],
|
||||
['valid parIRL=1, large cutoff', 1, '15', false, false, false],
|
||||
['parIRL=0 with parERC=0', 0, '0.0', false, false, false],
|
||||
['parIRL 0->1, negative parERC', 1, '-1', true, true, false],
|
||||
['parIRL 0->1, blank parERC', 1, '', true, true, false],
|
||||
['parIRL 0->1, non-numeric parERC', 1, 'abc', true, true, false],
|
||||
['non-numeric parIRL treated as off', 'x', '0.0', true, false, false],
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const [name, irl, erc, touchIRL, expectFilled, expectRefused] of cases) {
|
||||
reset(irl, erc);
|
||||
const before = fields.parERC.value;
|
||||
// The handler is wired to the parIRL widget only, so it runs when the user
|
||||
// changes parIRL and not when they go back and retype the cutoff themselves.
|
||||
if (touchIRL) { handleRecoilModeChange(); }
|
||||
const filled = fields.parERC.value !== before;
|
||||
const refused = runGuardVerdict() === 0;
|
||||
const refusedOk = (refused === expectRefused);
|
||||
// A refused run must actually say something in both places.
|
||||
const explained = !refused
|
||||
|| (shown.length > 0 && typeof statusMsg === 'string' && statusMsg.length > 0);
|
||||
|
||||
const ok = (filled === expectFilled) && refusedOk && explained;
|
||||
if (ok) { passed++; } else { failed++; }
|
||||
console.log((ok ? 'PASS ' : 'FAIL ') + name.padEnd(34)
|
||||
+ 'parERC=' + String(fields.parERC.value).padEnd(6)
|
||||
+ (filled ? 'filled in' : 'left alone').padEnd(13)
|
||||
+ (refused ? 'refused' : 'runs')
|
||||
+ (shown.length ? ' | "' + shown[shown.length - 1] + '"' : ''));
|
||||
}
|
||||
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user