Skip to content

nfsim: a load leaves the process's working directory alone, and a failed one names the file (#1314) - #1334

Merged
elyfmiller merged 4 commits into
lanl:mainfrom
Montana:fix/1314-nfsim-process-state
Nov 1, 2026
Merged

elyfmiller merged 4 commits into
lanl:mainfrom
Montana:fix/1314-nfsim-process-state

Conversation

@Montana

@Montana Montana commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #1314 (item 1, and item 2's documentation; the vendored RNG patch is pinned for a follow-up, as the issue allows).

The bug: a failed load left the host in the XML's directory

create_system() changed into the XML's directory, so NFsim could open a TFUN's file= by relative path, and changed back as plain code after initializeFromXML. A load that threw skipped the change back. initCompartments' convertToDouble on a malformed size is that throw: the one loader stage with no catch-all. The host was then left in the XML's directory, and the error was NFsim's bare message with no file name.

Reproduced on main with the issue's fixture, a compartment size="1.0abc" loaded from another directory:

before: RuntimeError: error in NFutil::convertToDouble("1.0abc")       cwd -> …/model
after:  RuntimeError: NfsimSimulator: Failed to load XML file: …/model/badsize.xml
        | error in NFutil::convertToDouble("1.0abc") | NFsim log: …    cwd unchanged

The fix: never change directory

As the issue prefers, there is no change of directory at all:

  • The rewrite: src/tfun_paths.hpp (write_tfun_absolute_xml) writes each relative file= inside <ListOfFunctions> as absolute, in the copy NFsim reads. Each one resolves against the original XML's directory, even when NFsim loads an override or seed-round copy from the temp directory. The inserted directory is XML-escaped, and the written value is kept as encoded.
  • What goes with the change of directory: the swallowed restore failure (catch (...) {}), and the window in which every other thread saw the XML's directory.
  • A load that throws is reported as one that returns NULL: the file, NFsim's reason, and its log.
-        std::filesystem::path previous_cwd;
-        bool changed_cwd = false;
-        try {
-            std::filesystem::path xml_dir = xml_file.parent_path();
-            if (!xml_dir.empty()) {
-                previous_cwd = std::filesystem::current_path();
-                std::filesystem::current_path(xml_dir);
-                changed_cwd = true;
-            }
-        } catch (...) {
+        std::optional<TempXmlGuard> tfun_guard;
+        if (auto absolute = bngsim::tfunpath::write_tfun_absolute_xml(xml_to_load.string(),
+                                                                      xml_file.parent_path())) {
+            tfun_guard.emplace(*absolute);
+            xml_to_load = tfun_guard->path();
+        }
 ...
-        {
+        try {
             const ScopedCNumericLocale c_numbers;
             system = NFinput::initializeFromXML(...);
+        } catch (const std::exception &e) {
+            throw failed(e.what());
+        } catch (...) {
+            throw failed("unknown exception");
         }

Item 2: streams and the RNG

Documented in the network-free guide ("What an NFsim run changes in your process") and in NfsimSimulator's class comment:

  • the working directory, which is no longer changed;
  • the std::cout/std::cerr swap, and that C++ output from other threads in the window is captured while C stdio is not;
  • NFutil's shared RNG.

The RNG leak is confirmed and pinned, not fixed. On a TLBR model, preparing a second session between a seeded session's two simulate() segments moves its trajectory, by up to 8 molecules. That test is xfail(strict=True), so the fix of the three vendored draws (ReactantList::pickRandom and reaction.cpp L771) flips it into a failure to remove. That fix changes same-seed trajectories, which nfsim_v1143_compat exists to keep, so it belongs in its own PR, the #283 item.

The leak shows only where the molecules a rule can fire on differ: in simple_system they are interchangeable, so the pick never shows. That case is kept as the control.

The new fixture tests/data/nfsim/tlbr_shared_rng.xml is tlbr_8 (wshlavacek/BNGL-Models@81c90d8f, already vendored under parity_checks/bng_parity/models), the BNG-XML from the bng_nf reference archive, with a provenance header.

Tests (python/tests/test_nfsim_process_state.py)

Fail on main, pass here:

  • a load that throws leaves the cwd and names the file;
  • the same through the override copy (set_param before initialize());
  • the same through the seed-round copy (a fractional seed concentration).

The throw tests drive the core NfsimSimulator: bngsim's Python layer refuses a compartment model before NFsim reads it (#1067).

Pins, passing on both:

  • a load that fails by returning NULL leaves the cwd;
  • a relative TFUN file= loads from elsewhere;
  • new: a relative TFUN resolves against the original XML through a copy;
  • new: a TFUN in a directory named a&b 'c' (escaping);
  • the streams work after a failed load;
  • exchangeable molecules hide the pick (the control).

xfail(strict): another session between segments moves a seeded TLBR trajectory.

Mutations of the C++, each rebuilt and caught:

  • the TFUN rewrite off: 2 TFUN tests fail;
  • resolving against the copy's directory: the through-a-copy test fails, the only test that sees it;
  • a bare rethrow: the names-the-file test fails;
  • no escaping: the escaping test fails.

Suites:

  • the full Python suite: 16,359 passed, 148 skipped, 2 xfailed;
  • the NFsim/TFUN files and the skip audit: 567 passed;
  • clang-format 17.0.6 (the pinned hook) and ruff 0.14.0 clean.

Out of scope, as the issue says: the ~75 vendored exit() calls.

…d that throws names the file (lanl#1314)

create_system() changed into the XML's directory so NFsim could open a
TFUN's file= by relative path, and changed back as plain code after the
load. An exception out of NFinput::initializeFromXML (initCompartments'
convertToDouble on a malformed size: the one loader stage with no
catch-all) skipped the change back, and left the host in the XML's
directory; its error was NFsim's bare message, with no file name.

The directory is now never changed. tfun_paths.hpp writes each relative
file= in <ListOfFunctions> absolute, against the original XML's
directory (not the override or seed-round copy's), escaped, in the copy
NFsim reads. That also closes the window in which every other thread
saw the XML's directory, and the swallowed restore failure goes with it.
A load that throws is reported as one that returns NULL: the file, the
reason, and NFsim's log.

The stream redirection and NFutil's shared RNG are documented in the
network-free guide and the class comment. That interleaved sessions do
not each reproduce their seed is pinned on a TLBR model, as a strict
xfail for the fix of the three vendored draws; on simple_system, whose
matching molecules are interchangeable, it does not show (the control).
@elyfmiller

Copy link
Copy Markdown
Collaborator

Thanks. We reviewed this independently, Opus 5.5 and Fable 5.1, each on our own build of cdc4f10 merged onto main c0877fa (clean), on macOS arm64. For XML as BNG2.pl writes it, the fix does what it says:

  • nothing changes the working directory;
  • a load that throws names the file and relays NFsim's log;
  • the cwd is unchanged on every failure path;
  • a relative TFUN file= loads from elsewhere, including through the override and seed-round copies.

One medium finding: the rewrite reads less of the XML than NFsim does, so some valid files that loaded on main no longer do, or read the wrong table. Then one tracking point and some low items. What we checked comes first, so it needn't be redone.

What we checked

check result
Fail before: the new file against main's core 3 failed (the three throw tests), 6 passed, 1 xfailed. As the body says.
After 9 passed, 1 xfailed
Full python/tests, BNGSIM_SKIP_AUDIT=strict 16,396 passed, 166 skipped, 1 xfailed (the new RNG pin), 0 failed, exit 0
The Windows NFsim leg's files (test_nfsim*.py) + skip audit, strict 343 passed, 1 skipped, 1 xfailed. This PR's own Windows run: 306 passed, 0 skipped, 1 xfailed, against the new floor of 298.
Mutation: the rewrite off the three TFUN tests fail (relative from elsewhere, through a copy, escaping; the body says two). So do 5 existing tests: 3 in test_nfsim.py, 2 in test_nfsim_time_table_breakpoints.py.
Mutation: no escaping of the inserted directory the escaping test fails
Mutation: resolving against the copy's directory the through-a-copy test fails
Read create_system() and both callers. TempXmlGuard removes the TFUN copy on every exit. The message names the user's xml_path, not the copy. NFsim reads Attribute("file"), trimmed, and opens it at load (parseFuncXML.cpp:492–600), so nothing is read after the copy goes.
Spellings that load from another cwd single quotes, ./x, ../sub/x, a subdirectory, absolute, an entity in the name (a&amp;b.dat), the attribute on its own line
Gates changelog check, ruff and clang-format 17.0.6 all clean; ci/test_floors.env merges with main's KLU change without overlap

1. The rewrite misses layouts NFsim accepts

write_tfun_absolute_xml works line by line:

  • it turns in_functions on at the <ListOfFunctions> line, but only looks for file= on later lines;
  • extract_attr matches the literal file=" or file='.

On main, the directory change made any relative path resolve. Here, a file= the scan misses is opened against the host's cwd. We loaded variants of tfun_simple.xml with the cwd elsewhere:

layout main this PR, empty cwd this PR, a same-named table in the cwd
as BNG2.pl writes it loads loads the right table
file = "tfun_data.dat" (spaces round =) loads Failed to load XML file reads the cwd's table
<ListOfFunctions><Function … file="…"> on one line loads Failed to load XML file reads the cwd's table
the whole file on one line loads Failed to load XML file reads the cwd's table

In the last column, the cwd held a tfun_data.dat with every multiplier 0. The run completes with no error, and its observables differ by up to 44 molecules from the same model run in its own directory. Each of us reproduced every row with our own script.

bngsim writes none of these layouts itself, so the reach is hand-edited, minified or third-party XML. But it worked before, and it can now be wrong without saying so.

The same rewrite already exists in Python for RuleMonkey's copies, in _rulemonkey_clock.py: _FILE = re.compile(r"""\bfile\s*=\s*(?:"[^"]*"|'[^']*')"""), applied per <Function> element rather than per line. Something like that, or scanning the <ListOfFunctions> line itself and allowing whitespace in extract_attr, would cover these. A test row next to the escaping one would pin it.

On the error text: when a path is missed, the message is Failed to load XML file: …/m.xml | NFsim log: … I failed at parsing your Global Functions. It names neither the table nor the working directory, so a user gets no hint of what changed.

2. The RNG xfail points at the issue this PR closes

The strict xfail's reason names #1314, and this PR closes #1314; #283, the index, is closed too. An open-issue search for pickRandom, NFutil RNG or SEED_RANDOM finds only #1314. Once this merges, nothing open tracks the vendored fix the xfail is waiting for.

A follow-up issue for the three draws, named in the reason, would keep it. The reason could also say that a re-vendor of NFsim that changes those draws flips it, so that red run explains itself.

Low

  1. Windows root-relative paths. On Windows fs::path("/data/t.dat").is_relative() is true, since it has no root name. So the XML's directory is prefixed, where main's directory change opened the path on the current drive. !has_root_path() leaves it as written. (By the standard's definition; we didn't run Windows.)
  2. The TFUN tests' oracle is np.isfinite. The table above shows a wrong table is finite too. Comparing with the run loaded from the XML's own directory, with a same-named decoy in the cwd, would catch a wrong table as well as a missing one.
  3. The strict xfail takes no raises=. The suite's other strict xfails pass one. raises=AssertionError keeps an unrelated error in _segments from counting as the known leak.

Not checked: Linux and Windows beyond CI; the fixture regenerated from tlbr_8.bngl (we checked its provenance header against the vendored source); the RNG fix itself, which is pinned here, as the PR says; perf (one extra read per load, and a copy only when a relative file= exists).

…l#1314)

Review items on lanl#1334:

- write_tfun_absolute_xml scanned the XML a line at a time, from the line
  after <ListOfFunctions>, for a literal file=" or file='. NFsim reads the
  attribute with TinyXML, which allows whitespace round the =, a value in
  either quote or none, and a tag that shares a line with others or runs
  over several. A file= the scan missed was opened against the process's
  working directory: the load failed there, or read a table of the same
  name, and ran on it without a word. The scan now reads the whole text,
  tag by tag, skipping comments and CDATA, and takes each <Function>'s
  attributes as TiXmlAttribute::Parse does.
- A path with a root is left as written (has_root_path, not is_relative),
  so on Windows /data/t.dat keeps its drive.
- The TFUN tests compare with the run from the XML's own directory, with a
  table of the same name and every multiplier 0 in the cwd, where they
  checked only that the observables were finite. New cases cover the
  layouts the line scan missed.
- The RNG xfail takes raises=AssertionError, and its reason cites lanl#1438,
  which tracks the three shared-RNG draws once lanl#1314 closes, and says a
  re-vendor that changes those draws flips it.
…rows bring (lanl#1314)

The review commit's nine TFUN layout rows take the windows-nfsim leg to 315
passed (d35445c on main 959a0ca: 315 passed, 1 xfailed, 0 skipped). That
is 17 above the floor of 298 set by cdc4f10, and NFSIM_PASSED_MAX_SLACK
allows 16, so the count guard failed the leg with every test green. The
floor goes to 307, 8 below the count as its note says, and the note's count
to 315.
@elyfmiller

Copy link
Copy Markdown
Collaborator

Following up on our review above: we've pushed the review items as two commits on top, d35445c and d5d1a0f, and we're merging now that CI is green on them. The working directory left alone, the error that names the file, the copies and the RNG pin all stand as you wrote them.

What d35445c and d5d1a0f change

  • Item 1: the rewrite reads the XML as NFsim's TinyXML does. write_tfun_absolute_xml (src/tfun_paths.hpp) no longer goes a line at a time. It reads the whole text, tag by tag:

    • it skips comments and CDATA;
    • <ListOfFunctions> opens the region wherever it stands, on a <Function>'s line or in a file on one line;
    • each <Function>'s attributes are read as TiXmlAttribute::Parse reads them: whitespace round the =, and a value in either quote or none. An unquoted value is quoted when the directory goes in.
    • an attribute's name is matched whole, so data-file= isn't file=. The old extract_attr matched file=" inside it, and rewrote that value instead.
  • Item 3. A path with a root is left as written (has_root_path(), not is_relative()), so on Windows /data/t.dat keeps its drive.

  • Item 4. The three TFUN tests compare, bit for bit, with the run loaded from the XML's own directory, with a tfun_data.dat of every multiplier 0 in the cwd. They had checked only that the observables were finite.

  • A new test, 9 layouts, each run from elsewhere with that decoy in the cwd:

    • spaces round =;
    • <ListOfFunctions><Function on one line;
    • the whole file on one line;
    • single quotes with spaces;
    • unquoted;
    • after a comment holding a <Function> and </ListOfFunctions>;
    • after CDATA holding </ListOfFunctions>;
    • after a data-file= attribute;
    • the attribute on its own line, which loaded before too, as a control.

    The comment and the CDATA hold the end tag so that a scan reading inside them would stop before the real <Function>. With a harmless comment, the mutation that stops skipping comments passed (see below).

  • Item 5. The RNG xfail takes raises=AssertionError, and its reason now says that a re-vendor of NFsim that changes those draws turns it into an XPASS.

  • Item 2. The xfail's reason cited A failed NFsim load leaves the host process in the XML's directory: create_system() changes the cwd and an exception from initializeFromXML skips the restore. The stream swap and global RNG reseed also need to be explicit and contained #1314, which this PR closes. It now cites NFsim draws some reaction picks from a process-wide RNG that every prepare reseeds: a seeded session's trajectory moves when another session is prepared between its segments #1438, which tracks the three draws and the SEED_RANDOM bridge, so the pin points at an open issue.

  • The Windows NFsim floor (d5d1a0f). The nine layout rows take that leg to 315 passed. That's 17 above the floor of 298 set in cdc4f10, and the guard allows 16, so on d35445c the leg went red with every test green. d5d1a0f sets the floor to 307, 8 below the count, as the note above it says, and that note's count to 315. It changes only ci/test_floors.env.

On the error text in item 1: the scan now reads what TinyXML reads, so it no longer misses a path. A table that really is missing fails as it does on main, so we left the message alone.

What we checked

check result
The commit d35445c is ee8ec7e3 with one line changed: the xfail's reason cites #1438 for #1314. The runs below are on ee8ec7e3. On d35445c's test file, the RNG control passes and the pin xfails with the new reason, and the gates are clean.
ee8ec7e3's test file on the PR's core (main 1c67030 + cdc4f10) 8 failed, 10 passed, 1 xfailed: every new layout but the control. The three TFUN tests pass there with the decoy too, since the PR loads BNG2.pl's layout from anywhere.
After (main 1c67030 + ee8ec7e3) 18 passed, 1 xfailed
Mutations of the scan, each rebuilt 5 of 5 caught. No whitespace round =: the two spaced rows fail. Unquoted values not read: the unquoted row. Comments not skipped: the comment row. CDATA not skipped: the CDATA row. The directory not escaped: the escaping test. The comment and CDATA mutations first survived a harmless comment; that's why those rows hold the end tag.
The Windows NFsim leg (test_nfsim*.py, with its two deselects) On main 4f86b95 + d5d1a0f: 315 passed, 2 deselected, 1 xfailed, 0 skipped, as CI counts it
Full python/tests, BNGSIM_SKIP_AUDIT=strict On main e2db9b9 + ee8ec7e3: 17,347 passed, 144 skipped, 1 xfailed (the RNG pin), 0 failed, exit 0. On main cec9b79 + the fix: 17,240 passed, 144 skipped, 1 xfailed, exit 0. On main 1c67030 + the fix, two builds: 17,219 passed, 144 skipped, 1 xfailed, exit 0, and 16,781 passed, 435 skipped, 1 xfailed. That second build's one failure is test_run_tests_sh's walk-up probe, which needs pybind11, missing from that venv. The C++ is the same in all four; ee8ec7e3 added only test rows after the earlier three ran.
#1385, which shares nfsim_simulator.cpp It merges clean. Its seed amounts are written into the seed-rounding copy, and the TFUN rewrite runs on that copy right after, against the original XML's directory. On main 16bf85c, with #1385 and #1372 merged, + ee8ec7e3, we ran three sets. This file with their two network-free files: 77 passed, 1 xfailed. test_nfsim*.py, test_tfun* and test_rulemonkey*: 663 passed, 1 skipped (a Linux-only /proc test), 1 xfailed. We also ran tfun_simple.xml from elsewhere, with the decoy in the cwd, three ways: plain, with set_species_amounts, and with that plus a set_param. Each run matched the same run from the XML's own directory. The same XML beside the decoy differs by 44, 18 and 29 molecules.
Gates clang-format 17.0.6, ruff 0.14.0 check and format, ci/changelog.py check, git diff --check: clean. The fragment and the network-free guide describe the rewrite as it now is, so neither changes.
Overlap We trial-merged the other 17 open PRs onto main 0c1732d + d5d1a0f. Only #1401 shares a file, ci/test_floors.env, where it raises the skip ceiling and this PR the NFsim floor; both changes merge in. All merge clean except #1346, #1382, #1411 and #1421, which conflict with main on their own, the same with or without this PR. #1372 and #1385, now merged, shared the network-free guide (other sections) and, for #1385, nfsim_simulator.cpp; see the row above. d5d1a0f merges clean onto main 0c1732d. Of the four PRs merged since CI ran (#1434, #1441, #1442, #1383), none adds tests to the NFsim or KLU legs. This PR adds 19 tests to the suite, which takes main's 17,668 to 17,687, inside the count guard's 17,879.
CI 18 checks passed on d5d1a0f, none failed. The Windows NFsim leg passed 315, with the floor at 307.

@elyfmiller
elyfmiller merged commit 3616b39 into lanl:main Nov 1, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants