diff --git a/ToDo.txt b/ToDo.txt
index 22f9c42..2ba3f8f 100644
--- a/ToDo.txt
+++ b/ToDo.txt
@@ -17,16 +17,95 @@ DONE - Cap valEnergy magnitude at 60000 eV (positive incidence mode and the
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.
-- Cap client-controlled numberProj magnitude: an unbounded projectile count (e.g.
- 1e9) does not terminate within the 300 s watchdog and can flood scan points
- (empirically verified: 1e9 did not finish in 15 s). GUI ProjNumberLimit() is
- currently dead code (defined at TrimSPlib.js:3516, never wired to an event).
+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
=================================================
diff --git a/TrimSP.html b/TrimSP.html
index 7fc8566..7ff11db 100644
--- a/TrimSP.html
+++ b/TrimSP.html
@@ -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.
-
Calculate collision recoils.
+
Calculate collision recoils. Requires a
+ parERC recoil cutoff above zero; 0.02 is filled in
+ automatically.
-
+
diff --git a/TrimSPlib.js b/TrimSPlib.js
index c698610..5fea487 100644
--- a/TrimSPlib.js
+++ b/TrimSPlib.js
@@ -3804,6 +3804,53 @@ var TRIMSP_ENERGY_CAP_EV = 60000;
// 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) {
@@ -4056,6 +4103,46 @@ async function runSimulationSequence() {
}
}
+ // 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.
// Each job gets its own input file name and a deterministic seed offset so
// the results are reproducible even when runs finish out of order.
diff --git a/fortran/trimspNL.F b/fortran/trimspNL.F
index 7e8369f..320315e 100644
--- a/fortran/trimspNL.F
+++ b/fortran/trimspNL.F
@@ -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
diff --git a/tests/recoil-cutoff-test.js b/tests/recoil-cutoff-test.js
new file mode 100644
index 0000000..ff65c15
--- /dev/null
+++ b/tests/recoil-cutoff-test.js
@@ -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);