Keep multi-word values intact when loading toolchain/modules - #1702
Keep multi-word values intact when loading toolchain/modules#1702Mohit-Ak wants to merge 2 commits into
Conversation
A `KEY=value` line in toolchain/modules was exported with `eval "export $_entry"`, which word-splits the value. Everything after the first word was treated as a further name to export; that failed, and the failure went to stderr, which `./mfc.sh load` output is routinely redirected away from. The variable ended up set but holding only its first word, with nothing to say so. The consequences are not cosmetic: on Frontier, CRAY_CCE_LLD_ARGS needs two -plugin-opt flags for CCE 21, one of which works around a code-generation bug that silently discards stores. Written unquoted, only the first was applied -- the build succeeded and the numerical workaround was simply absent. Splitting on the first '=' instead, as the issue suggests, fixes multi-word values but breaks the multi-assignment lines that are already in the file (CC=nvc CXX=nvc++ FC=nvfortran would collapse into CC). So the loader now walks the line word by word and starts a new assignment only at a word shaped like an identifier followed by '=', treating anything else as a continuation of the current value. Values are still expanded once so "$VAR" references to earlier exports keep working, but the expanded result is exported directly rather than re-evaluated, which is what dropped the extra words before. Fixes MFlowCode#1690
Code reviewThe core fix looks correct. I diffed old-vs-new behavior across all 23 Found 1 issue worth addressing, plus three minor notes.
Before the export loop ever runs, MFC/toolchain/bootstrap/modules.sh Lines 153 to 162 in 38c24b2 Any word of a multi-word value that does not itself contain The originally reported #1690 case works only incidentally, because every one of its words happens to contain MFC/toolchain/mfc/bootstrap_tests/test_modules_env.py Lines 104 to 127 in 38c24b2
Minor notes, take or leave:
MFC/toolchain/bootstrap/modules.sh Lines 105 to 118 in 38c24b2
MFC/toolchain/bootstrap/modules.sh Lines 128 to 130 in 38c24b2
Lines 39 to 46 in 38c24b2 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
There was a problem hiding this comment.
Pull request overview
This PR fixes ./mfc.sh load’s export behavior so toolchain/modules entries with multi-word values (e.g., compiler/linker flags) are exported intact without truncation, while still supporting the existing “multiple assignments per line” format used across cluster slugs.
Changes:
- Replaces
eval "export $_entry"with a new__export_assignments()parser that groups tokens into assignments while preserving space-containing values. - Keeps one-round
$VARexpansion for values, while avoiding re-evaluation that would re-trigger word-splitting. - Adds pytest coverage that drives the real shell function and sweeps all assignment-bearing lines in
toolchain/modules.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| toolchain/bootstrap/modules.sh | Introduces __export_assignments() and routes module-line exports through it to preserve multi-word values safely. |
| toolchain/mfc/bootstrap_tests/test_modules_env.py | Adds regression tests covering existing assignment shapes, multi-word values, $VAR expansion, glob safety, and a sweep over shipped module lines. |
| toolchain/mfc/bootstrap_tests/init.py | Establishes a package namespace for bootstrap shell-script tests. |
| # One round of expansion (so "$VAR" references still work), then export | ||
| # the result directly -- re-evaluating it would word-split the value | ||
| # again, which is the bug this function exists to avoid. | ||
| _val="$(eval "echo \"$_val\"")" |
There was a problem hiding this comment.
Good catch, and this turned out to be a live bug rather than only a hardening point. Bash's builtin echo consumes -n/-e/-E as options, so the old expansion silently dropped them:
MYVAR=-n -> [] # old: eval echo
MYVAR=-e -> []
MYVAR=-E foo -> [-E foo]
I went with the positional parameters rather than printf:
eval "set -- $_val"
_val="$*"printf '%s' was my first attempt and it fails on the quoted multi-word case — CRAY_CCE_LLD_ARGS="-O2 -g" expands to two arguments, and %s only consumes the first per format cycle, so the value came back as -O2-g with the separator lost. $* joins on the first character of IFS, which reproduces echo's spacing exactly while going nowhere near option parsing. That showed up as a real test failure (test_quoted_multi_word_value_survives_intact) before I switched.
After the change:
MYVAR=-n -> [-n]
MYVAR=-e -> [-e]
MYVAR=-E foo -> [-E foo]
MYVAR=-n -e trailing -> [-n -e trailing]
Covered by test_value_that_looks_like_an_echo_option_survives, parametrized over those four.
| Returns the resulting environment as a dict, so a test can assert on the | ||
| variables the entry was supposed to set. | ||
| """ | ||
| preamble = "\n".join(f"export {k}={v}" for k, v in (preset_env or {}).items()) |
There was a problem hiding this comment.
Agreed — fixed. The preset-env preamble now goes through shlex.quote:
preamble = "\n".join(f"export {k}={shlex.quote(v)}" for k, v in (preset_env or {}).items())No current test needs it (the presets are /opt/cuda and /sw/afar), but the harness generates bash source text, so an unquoted value with a space or a metacharacter would change the meaning of the generated script rather than just failing the assertion — a confusing failure to debug later. The _quote() helper already did this for the entry under test; the preamble was the inconsistent path.
Follow-up to the review on MFlowCode#1702. The export side of a toolchain/modules line now understands multi-word values, but the step that decides which words are module names still dropped every word containing '=' and passed the rest to 'module load'. Any word of a multi-word value that lacks an '=' of its own survived that filter, so an entry like LDFLAGS=-L$CUDA_HOME/lib64 -lcudart handed '-lcudart' to 'module load', which fails and aborts the loader before a single variable is exported. No line shipped in toolchain/modules hits this today (every word of every multi-word value happens to contain '='), so it was latent rather than broken -- but it is exactly the shape the export fix invites contributors to write next. Both readers now share one rule, in __module_words() and __export_assignments(): a word shaped like an identifier followed by '=' opens a new assignment and everything after it is that assignment's value; words ahead of the first assignment are module names. Classification also moved to a per-line loop, since __extract() concatenates matching lines and one line's trailing value would otherwise swallow the next line's module names. Two smaller points from the same review: * values are expanded through the positional parameters instead of 'echo', so a value of '-n' or '-e' is exported rather than being eaten as an option to the builtin; * __export_assignments() now ends on 'return 0' via the glob-guard helper and unsets its nested helper, so it cannot report failure when the caller had already set -f.
|
Thanks — point 1 is right, and it's the more interesting half of the bug. Pushed The classification step. I reproduced it before touching anything, running the old So exactly as you described: a word survives the filter iff it lacks an I went with extending the parser rather than narrowing the tests, since the alternative is a fix whose own rationale documents a shape that doesn't work end to end. Both readers now share one rule, factored into One thing that fell out while wiring it up: the classification had to move to a per-line loop. Verification. Sweep over every one of the 108 content lines in Nothing shipped today changes; only the two latent shapes do. There's a
For the RED side I kept the new tests and surgically restored just the old classification and old expansion in a throwaway worktree: 6 failed, 20 passed — the 20 passing are the control, confirming the failures are the behaviour and not the harness. (Reverting On your notes 2–4. (3), the return status. Fixed — worth doing since factoring the glob guard out meant touching those lines anyway. (2), (4), test count vs CLAUDE.md. Fair, and the number went up rather than down — the rework needed the classification side covered. My reasoning: the fix is a parser rewrite whose whole risk profile is "some input shape gets split differently than before", so per-shape cases are the thing that actually protects the behaviour, and Copilot's two inline notes are addressed as well; I replied under those with the details. The |
A
KEY=valueline intoolchain/moduleswas exported by the loader like this:The entry isn't quoted, so
evalword-splits the value. Everything after the first word is treated as a further name to export, that fails, and the failure goes to stderr — which./mfc.sh loadoutput is routinely redirected away from. The result is a variable that looks set but holds only its first word, with nothing anywhere to say so:As #1690 notes, that's not cosmetic: on Frontier
CRAY_CCE_LLD_ARGSneeds two-plugin-optflags for CCE 21, one of which works around a code-generation bug that silently discards stores. Written unquoted, only the first is applied — the build succeeds, the tests run, and the numerical workaround is simply absent.Why not the split-on-first-
=fixThe issue suggests splitting on the first
=and exporting without re-evaluating. That does fix multi-word values, but it breaks the multi-assignment lines already in the file, because those carry several assignments per line. I swept both approaches over every assignment line currently intoolchain/modules:Eight lines in the file have this shape (
b-gpu,a-gpu,w-gpu,e-gpu,p-gpu,pifx-cpu×3,c-cpu,i-all, …), so split-on-=would quietly stop setting the compilers on most clusters.What this does instead
The loader now walks the line word by word and starts a new assignment only at a word shaped like an identifier followed by
=; anything else is a continuation of the current value. Both shapes then survive, including a multi-word value followed by another assignment.Values still go through one round of expansion so
"$VAR"references to previously-exported variables keep working (NVHPC_CUDA_HOME=$CUDA_HOME), but the expanded result is exported directly rather than re-evaluated — re-evaluating is what dropped the extra words. Word-splitting runs underset -fso a value like-Wl,*can't pick up filenames from the working directory, and the previous globbing state is restored afterward.Testing
toolchain/mfc/bootstrap_tests/test_modules_env.pydrives the real__export_assignmentsfunction out ofmodules.sh(rather than a copy), so it fails if the implementation regresses. It covers every assignment shape currently intoolchain/modules, the multi-word cases from the issue,$VARexpansion inside a multi-word value, glob safety, and a sweep asserting that every assignment line shipped intoolchain/modulesexports each of its names.Against the unpatched loader, 4 of the 12 fail, with the 8 existing-shape tests still passing:
With the fix:
(382 vs. 370 on master — the 12 new tests, and ruff clean.)
./mfc.sh precheckpasses all seven gates (formatting, spelling, toolchain lint, source lint, doc references, parameter docs, example cases).I also ran the real loader loop end-to-end for several cluster slugs to confirm nothing changed for existing configurations:
One judgement call worth flagging: I put the new tests under
toolchain/mfc/bootstrap_tests/since there was no existing home for shell-level tests. Happy to move them if you'd rather they lived elsewhere.Fixes #1690