From be0b4da437cb14b4e217429cec4dab44328849db Mon Sep 17 00:00:00 2001 From: Adrien Cotte Date: Wed, 26 Aug 2026 20:12:57 +0200 Subject: [PATCH 1/4] Preserve sticky module requirements during purge Purge skips sticky and super-sticky modules but still attempts to unload their non-sticky requirements. This reports an unrelated dependency error without explaining why the requirement remains loaded. Preserve requirements needed by retained sticky modules and apply the sticky_purge reporting mode to their skipped unload. Fixes #582 Assisted-by: Codex:gpt-6-astra Signed-off-by: Adrien Cotte --- .hunspell.en.dic | 1 + NEWS.rst | 4 +- doc/source/changes.rst | 9 ++ doc/source/cookbook/sticky-modules.rst | 52 ++++++++ doc/source/design/evaluation-errors.rst | 7 + doc/source/design/module-tags.rst | 7 + doc/source/design/sticky-modules.rst | 19 ++- doc/source/module.rst | 29 ++++- tcl/modeval.tcl | 28 ++++ tcl/report.tcl.in | 4 + tcl/subcmd.tcl.in | 5 + testsuite/modules.00-init/005-init_ts.exp | 8 ++ testsuite/modules.50-cmds/460-sticky.exp | 2 +- .../modules.50-cmds/461-super-sticky.exp | 8 +- .../modules.50-cmds/464-sticky_purge.exp | 121 ++++++++++++++++++ 15 files changed, 289 insertions(+), 15 deletions(-) diff --git a/.hunspell.en.dic b/.hunspell.en.dic index fe29298d0..2dfbed543 100644 --- a/.hunspell.en.dic +++ b/.hunspell.en.dic @@ -497,6 +497,7 @@ gh ghcommit gitlog gmake +gpt grp gz gzip diff --git a/NEWS.rst b/NEWS.rst index 0769f949b..b7fe5c574 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -251,6 +251,9 @@ Modules 5.7.0 (not yet released) * Doc: add the :ref:`user-guide` document that explains a selection of useful but lesser known features through practical examples and common use cases. +* Keep non-sticky requirements of sticky modules loaded during a + :subcmd:`purge` and explicitly report why their unload is skipped according + to the :mconfig:`sticky_purge` configuration. (fix issue #582) * Init: fix command injection in Bash completion when module names contain shell meta-characters. Completion candidates were passed to ``compgen -W`` which evaluates command substitution syntax. (fix `CVE-2026-85013`_ found @@ -267,7 +270,6 @@ Modules 5.7.0 (not yet released) .. _CVE-2026-85013: https://github.com/envmodules/modules/security/advisories/GHSA-8hrw-p88g-qhmg - .. _5.6 release notes: Modules 5.6.2 (2026-09-07) diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 0a83085bf..75e9360a7 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -648,6 +648,15 @@ Module Sub-Commands these variables. Exception is made for :envvar:`MODULEPATH` environment variable where the mechanism still applies. +:subcmd:`purge` + + Starting with version 5.7, direct and indirect requirements of retained + sticky and super-sticky modules are kept loaded, even if those requirements + are not sticky themselves. The :mconfig:`sticky_purge` configuration + controls reporting for their skipped unload as well as for sticky modules. + With :option:`--force`, sticky modules and their requirements can be + unloaded, but super-sticky modules and their requirements remain loaded. + :subcmd:`refresh` Sub-command evaluates all loaded modulefiles and re-apply the non-persistent diff --git a/doc/source/cookbook/sticky-modules.rst b/doc/source/cookbook/sticky-modules.rst index eb6e8b98a..182616d07 100644 --- a/doc/source/cookbook/sticky-modules.rst +++ b/doc/source/cookbook/sticky-modules.rst @@ -170,4 +170,56 @@ for load in the initialization RC file |file etcdir_initrc|: module load core module load compiler/compB +Preserving requirements during purge +------------------------------------ + +Starting with Modules v5.7, :subcmd:`purge` also preserves the requirements +of retained sticky or super-sticky modules. + +A base environment can group *core* and *compiler* under a single *env/base* +module. In this example, *core* and *compiler* have no sticky tag. The +:file:`env/base` modulefile declares both as requirements: + +.. code-block:: tcl + + #%Module + prereq core + prereq compiler + +Only *env/base* is tagged *super-sticky*, in :file:`env/.modulerc`: + +.. code-block:: tcl + + #%Module + module-tag super-sticky env/base + +With :mconfig:`sticky_purge` set to ``warning``, a purge explains why each +module remains loaded: + +.. parsed-literal:: + + :ps:`$` module config sticky_purge warning + :ps:`$` module load env/base + :ps:`$` module purge + Unloading :sgrhi:`env/base` + :sgrwa:`WARNING`: Unload of super-sticky module skipped + + Unloading :sgrhi:`compiler` + :sgrwa:`WARNING`: Unload of super-sticky module requirement skipped + + Unloading :sgrhi:`core` + :sgrwa:`WARNING`: Unload of super-sticky module requirement skipped + :ps:`$` module list + Currently Loaded Modulefiles: + 1) core 2) compiler 3) :sgrss:`env/base` + + Key: + :sgrss:`super-sticky` + +Both requirements remain loaded even though they are not sticky themselves. +The same protection applies to indirect requirements and to a forced purge +because *env/base* is super-sticky. Setting ``sticky_purge`` to ``silent`` +suppresses these warnings; its default value, ``error``, reports errors for +the skipped unloads. + .. vim:set tabstop=2 shiftwidth=2 expandtab autoindent: diff --git a/doc/source/design/evaluation-errors.rst b/doc/source/design/evaluation-errors.rst index b7d1881c1..4c148e77d 100644 --- a/doc/source/design/evaluation-errors.rst +++ b/doc/source/design/evaluation-errors.rst @@ -336,6 +336,13 @@ In case of error, *continue on error* behavior is applied. If purge is added in the value list of :mconfig:`abort_on_error` configuration option and if force mode is not set, *abort on error* behavior applies. +Starting Modules 5.7, unload of a requirement needed by a retained sticky or +super-sticky module is skipped according to :mconfig:`sticky_purge`. +``error`` raises an error and follows the above abort or continue behavior. +``warning`` and ``silent`` skip unload without an error exit code. This also +applies to indirect requirements and, even in force mode, to requirements of +super-sticky modules. + Unload sub-command ------------------ diff --git a/doc/source/design/module-tags.rst b/doc/source/design/module-tags.rst index bd5282629..acef73946 100644 --- a/doc/source/design/module-tags.rst +++ b/doc/source/design/module-tags.rst @@ -56,6 +56,13 @@ Specification - ``super-sticky``: loaded module cannot be unloaded even if forced, it stills can be unloaded if reloaded afterward (see :ref:`sticky-modules`) - ``keep-loaded``: auto_handling mechanism does not unload auto-loaded module +- Starting Modules 5.7, :subcmd:`purge` also preserves the direct and + indirect requirements of retained ``sticky`` or ``super-sticky`` modules. + Requirements do not inherit these tags. Their unload is skipped according + to :mconfig:`sticky_purge`, including when a requirement has no sticky tag. + With :option:`--force`, only requirements needed by retained super-sticky + modules are protected by this mechanism. + - Tags inherited from module state cannot be set with ``module-tag`` command - An error is otherwise thrown diff --git a/doc/source/design/sticky-modules.rst b/doc/source/design/sticky-modules.rst index cdcc4fca2..417bc50a1 100644 --- a/doc/source/design/sticky-modules.rst +++ b/doc/source/design/sticky-modules.rst @@ -48,10 +48,16 @@ Specification - it means stickiness applies to the module version - thus these versions targeted specifically with ``module-tag`` cannot be swapped by another version from same module -- When a super-sticky module depends on a non-super-sticky module +- Starting Modules 5.7, ``purge`` preserves requirements of retained sticky + and super-sticky modules - - If a forced ``purge`` command occurs, the dependent module will be unloaded - - Which let the super-sticky module with a missing dependency + - Direct and indirect requirements are preserved, even without a sticky tag + - Only strong requirements are preserved: an alternative requirement may + be unloaded while another loaded module still satisfies it + - A forced ``purge`` unloads sticky modules and can unload their + requirements, but preserves super-sticky modules and their requirements + - Before version 5.7, a forced ``purge`` could unload a non-super-sticky + requirement and leave a super-sticky module with a missing dependency - Starting Modules 5.2, sticky modules are unloaded @@ -88,6 +94,13 @@ Specification code - when set to ``silent``, no message reported, no error exit code + - Starting Modules 5.7, this configuration also applies to skipped unload + of requirements of retained sticky or super-sticky modules + - Requirement unload is reported as ``Unload of sticky module requirement + skipped`` or ``Unload of super-sticky module requirement skipped`` + - If both sticky and super-sticky modules require a module, the + super-sticky tag takes precedence in this message + - Even when ``sticky_purge`` is set to ``silent``, a warning message is reported when unload of sticky module is forced during a purge. diff --git a/doc/source/module.rst b/doc/source/module.rst index e0f58d3e6..9a957f7d0 100644 --- a/doc/source/module.rst +++ b/doc/source/module.rst @@ -341,7 +341,8 @@ switches are accepted: On :subcmd:`clear` sub-command, skip the confirmation dialog and proceed. On :subcmd:`purge` sub-command also unload `sticky modules`_ and modulefiles - that are depended by non-unloadable modules. + that are depended by non-unloadable modules, except requirements of retained + super-sticky modules. .. only:: html or latex @@ -1772,6 +1773,9 @@ Module Sub-Commands Error behavior when unloading sticky or super-sticky module during a module :subcmd:`purge`. + Also applies to modules whose unload is skipped because they are required, + directly or indirectly, by a retained sticky or super-sticky module. + Raise an ``error`` (default) or emit a ``warning`` or be ``silent``. It can be changed at installation time with :instopt:`--with-sticky-purge` option. The :envvar:`MODULES_STICKY_PURGE` environment variable is defined by @@ -2379,9 +2383,15 @@ Module Sub-Commands Unload all loaded *modulefiles*. + Sticky and super-sticky modules are kept loaded, along with their required + modules, including indirect requirements. Unload attempts for these modules + are skipped according to the :mconfig:`sticky_purge` configuration: raise an + ``error`` (default), emit a ``warning`` or remain ``silent``. + When the :option:`--force` option is set, also unload `sticky modules`_, modulefiles that are depended by non-unloadable modules and modulefiles - raising an evaluation error. + raising an evaluation error. Super-sticky modules and their requirements + remain loaded even with :option:`--force`. If one modulefile unload evaluation raises an error, purge sequence continues: unloaded modules prior the evaluation error are kept unloaded and @@ -2398,6 +2408,9 @@ Module Sub-Commands .. versionchanged:: 5.4 Support for :mconfig:`abort_on_error` configuration option added + .. versionchanged:: 5.7 + Preserve requirements of retained sticky and super-sticky modules + .. subcmd:: refresh Force a refresh of all non-persistent components of currently loaded modules. @@ -3545,6 +3558,14 @@ Modules are designated sticky by associating them the ``sticky`` or the ``super-sticky`` :ref:`module tag` with the :mfcmd:`module-tag` modulefile command. +Starting with version 5.7, :subcmd:`purge` also preserves the direct and +indirect requirements of retained sticky or super-sticky modules, even if +these requirements have no sticky tag. The :mconfig:`sticky_purge` +configuration applies to their skipped unload. With :option:`--force`, +sticky modules can be unloaded, but super-sticky modules and their +requirements remain loaded. If several loaded modules satisfy an alternative +requirement, only the last necessary one is preserved. + When stickiness is defined over the generic module name (and not over a specific module version, a version list or a version range), sticky or super-sticky module can be swapped by another version of module. For instance @@ -6131,6 +6152,9 @@ ENVIRONMENT :subcmd:`purge`, raise an ``error`` or emit a ``warning`` message or be ``silent``. + Also applies to the skipped unload of modules required, directly or + indirectly, by a retained sticky or super-sticky module. + This environment variable value supersedes the default value set in the :mconfig:`sticky_purge` configuration option. It can be defined with the :subcmd:`config` sub-command. @@ -6436,4 +6460,3 @@ SEE ALSO -------- :ref:`envml(1)`, :ref:`ml(1)`, :ref:`modulecmd(1)`, :ref:`modulefile(5)` - diff --git a/tcl/modeval.tcl b/tcl/modeval.tcl index 0d59392b5..b527411d6 100644 --- a/tcl/modeval.tcl +++ b/tcl/modeval.tcl @@ -959,6 +959,19 @@ proc isModuleSticky {mod} { $mod sticky 1] && ![getState force])}] } +proc getModuleStickyDependentTag {mod} { + set sticky_tag {} + foreach dep_mod [getDependentLoadedModuleList [list $mod] 1 0 0 0] { + if {[isModuleTagged $dep_mod super-sticky 1]} { + set sticky_tag super-sticky + break + } elseif {![getState force] && [isModuleTagged $dep_mod sticky 1]} { + set sticky_tag sticky + } + } + return $sticky_tag +} + proc saveLoadedReqOfUnloadingModule {unload_mod} { # fetch requirements of unloading module set ::g_savedLoReqOfUnloadMod($unload_mod)\ @@ -1172,6 +1185,21 @@ proc failOrSkipUnloadIfSticky {modname modfile} { return 0 } +proc failOrSkipUnloadIfRequiredBySticky {mod} { + set sticky_tag [getModuleStickyDependentTag $mod] + if {$sticky_tag eq {}} { + return 0 + } + + set msg [getStickyReqUnloadMsg $sticky_tag] + switch -- [getConf sticky_purge] { + error {knerror $msg} + warning {reportWarning $msg} + } + + return 1 +} + # ;;; Local Variables: # ;;; Mode: tcl-mode # ;;; tcl-indent-level: 3 diff --git a/tcl/report.tcl.in b/tcl/report.tcl.in index 2938a7824..d893b9bd4 100644 --- a/tcl/report.tcl.in +++ b/tcl/report.tcl.in @@ -1115,6 +1115,10 @@ proc getStickyUnloadMsg {{tag sticky}} { return "Unload of $tag module skipped" } +proc getStickyReqUnloadMsg {{tag sticky}} { + return "Unload of $tag module requirement skipped" +} + proc getStickyForcedUnloadMsg {} { return {Unload of sticky module forced} } diff --git a/tcl/subcmd.tcl.in b/tcl/subcmd.tcl.in index d53928dfe..226f40aec 100644 --- a/tcl/subcmd.tcl.in +++ b/tcl/subcmd.tcl.in @@ -1577,6 +1577,11 @@ proc cmdModuleUnload {context match auto force onlyureq args} { set prereq_list [getDependentLoadedModuleList [list $modname]] set prereq_loaded_list [getDependentLoadedModuleList [list $modname]\ 1 1 0 0] + if {[llength $prereq_loaded_list] &&\ + [getState commandname] eq {purge} &&\ + [failOrSkipUnloadIfRequiredBySticky $modname]} { + continue + } if {[llength $prereq_loaded_list] && (![getConf auto_handling] ||\ !$auto)} { # force mode should not affect if we only look for mods w/o dep diff --git a/testsuite/modules.00-init/005-init_ts.exp b/testsuite/modules.00-init/005-init_ts.exp index 34dbdacc3..4ce993059 100644 --- a/testsuite/modules.00-init/005-init_ts.exp +++ b/testsuite/modules.00-init/005-init_ts.exp @@ -455,6 +455,14 @@ set warn_stickyunload "$::warn_msgs: Unload of sticky module skipped" set err_stickyunloadf "$::warn_msgs: Unload of sticky module forced" set err_superstickyunload "$::error_msgs: Unload of super-sticky module skipped" set warn_superstickyunload "$::warn_msgs: Unload of super-sticky module skipped" +set err_stickyrequnload "$::error_msgs: Unload of sticky module requirement\ + skipped" +set warn_stickyrequnload "$::warn_msgs: Unload of sticky module requirement\ + skipped" +set err_superstickyrequnload "$::error_msgs: Unload of super-sticky module\ + requirement skipped" +set warn_superstickyrequnload "$::warn_msgs: Unload of super-sticky module\ + requirement skipped" set err_reqfull "$::error_msgs: Module version must be specified to load module" set err_nomodloaded "$error_msgs: $msg_nomodloaded" set err_modfromdiffpathloaded "$error_msgs: Module already loaded from a different modulepath" diff --git a/testsuite/modules.50-cmds/460-sticky.exp b/testsuite/modules.50-cmds/460-sticky.exp index cc651b42a..e61b41519 100644 --- a/testsuite/modules.50-cmds/460-sticky.exp +++ b/testsuite/modules.50-cmds/460-sticky.exp @@ -435,7 +435,7 @@ lappend ans [list set __MODULES_LMPREREQ sticky/1.0&foo|baz] lappend ans [list set _LMFILES_ $mp/foo/2.0:$mp/sticky/1.0] lappend ans [list set LOADEDMODULES foo/2.0:sticky/1.0] lappend ans [list ERR] -set tserr [msg_unload {sticky/1.0 } $err_stickyunload]\n\n[msg_unload foo/2.0 [err_deplo sticky/1.0]] +set tserr [msg_unload {sticky/1.0 } $err_stickyunload]\n\n[msg_unload foo/2.0 $err_stickyrequnload] testouterr_cmd_re sh {purge} $ans $tserr set ans [list] lappend ans [list unset __MODULES_LMPREREQ] diff --git a/testsuite/modules.50-cmds/461-super-sticky.exp b/testsuite/modules.50-cmds/461-super-sticky.exp index 56d00ca58..d7d3790fa 100644 --- a/testsuite/modules.50-cmds/461-super-sticky.exp +++ b/testsuite/modules.50-cmds/461-super-sticky.exp @@ -352,14 +352,8 @@ lappend ans [list set __MODULES_LMPREREQ sticky/1.0&foo|baz] lappend ans [list set _LMFILES_ $mp/foo/2.0:$mp/sticky/1.0] lappend ans [list set LOADEDMODULES foo/2.0:sticky/1.0] lappend ans [list ERR] -set tserr [msg_unload {sticky/1.0 } $err_superstickyunload]\n\n[msg_unload foo/2.0 [err_deplo sticky/1.0]] +set tserr [msg_unload {sticky/1.0 } $err_superstickyunload]\n\n[msg_unload foo/2.0 $err_superstickyrequnload] testouterr_cmd_re sh {purge} $ans $tserr -set ans [list] -lappend ans [list set LOADEDMODULES sticky/1.0] -lappend ans [list set _LMFILES_ $mp/sticky/1.0] -lappend ans [list set __MODULES_LMPREREQ sticky/1.0&foo|baz] -lappend ans [list ERR] -set tserr [msg_unload {sticky/1.0 } $err_superstickyunload]\n\n[msg_unload foo/2.0 [err_deplof sticky/1.0]] testouterr_cmd_re sh {purge -f} $ans $tserr diff --git a/testsuite/modules.50-cmds/464-sticky_purge.exp b/testsuite/modules.50-cmds/464-sticky_purge.exp index dc14ea1fa..b939566dd 100644 --- a/testsuite/modules.50-cmds/464-sticky_purge.exp +++ b/testsuite/modules.50-cmds/464-sticky_purge.exp @@ -97,6 +97,127 @@ testouterr_cmd sh {purge} $ans {} testouterr_cmd sh {purge -f} $ans {} +# super-sticky module with non-sticky requirement +setenv_var TESTSUITE_STICKY purge_req +setenv_loaded_module [list sticky1/1.0 sticky2/1.0] [list\ + $mp/sticky1/1.0 $mp/sticky2/1.0] +setenv_var __MODULES_LMTAG sticky2/1.0&super-sticky +setenv_var __MODULES_LMPREREQ sticky2/1.0&sticky1/1.0|sticky1/2.0 + +set ans [list] + +setenv_var MODULES_STICKY_PURGE error +set anserr $ans +lappend anserr [list ERR] +set tserr [msg_unload {sticky2/1.0 }\ + $err_superstickyunload]\n\n[msg_unload sticky1/1.0\ + $err_superstickyrequnload] +testouterr_cmd sh {purge} $anserr $tserr +testouterr_cmd sh {purge -f} $anserr $tserr + +setenv_var MODULES_STICKY_PURGE warning +set tserr [msg_unload {sticky2/1.0 }\ + $warn_superstickyunload]\n\n[msg_unload sticky1/1.0\ + $warn_superstickyrequnload] +testouterr_cmd sh {purge} $ans $tserr +testouterr_cmd sh {purge -f} $ans $tserr + +setenv_var MODULES_STICKY_PURGE silent +testouterr_cmd sh {purge} $ans {} +testouterr_cmd sh {purge -f} $ans {} + +unsetenv_var TESTSUITE_STICKY + + +# sticky module with non-sticky requirement, including forced unload +setenv_var __MODULES_LMTAG sticky2/1.0&sticky +foreach {report_mode msg reqmsg} [list error $err_stickyunload\ + $err_stickyrequnload warning $warn_stickyunload\ + $warn_stickyrequnload silent {} {}] { + setenv_var MODULES_STICKY_PURGE $report_mode + set ans [list] + if {$report_mode eq {error}} { + lappend ans [list ERR] + } + set tserr {} + if {$report_mode ne {silent}} { + set tserr [msg_unload {sticky2/1.0 } $msg]\n\n[msg_unload\ + sticky1/1.0 $reqmsg] + } + testouterr_cmd sh {purge} $ans $tserr + + set ans [list] + foreach var {__MODULES_LMPREREQ _LMFILES_ LOADEDMODULES __MODULES_LMTAG} { + lappend ans [list unset $var] + } + set tserr [msg_unload {sticky2/1.0 } $err_stickyunloadf] + testouterr_cmd sh {purge -f} $ans $tserr +} + + +# registered dependency chain: sticky3 -> sticky2 -> sticky1 +setenv_loaded_module [list sticky1/1.0 sticky2/1.0 sticky3/1.0] [list\ + $mp/sticky1/1.0 $mp/sticky2/1.0 $mp/sticky3/1.0] +setenv_var __MODULES_LMTAG sticky3/1.0&super-sticky +setenv_var __MODULES_LMPREREQ\ + sticky2/1.0&sticky1/1.0|sticky1/2.0:sticky3/1.0&sticky2/1.0 +foreach auto_handling {0 1} { + setenv_var MODULES_AUTO_HANDLING $auto_handling + foreach {report_mode msg reqmsg} [list error $err_superstickyunload\ + $err_superstickyrequnload warning $warn_superstickyunload\ + $warn_superstickyrequnload silent {} {}] { + setenv_var MODULES_STICKY_PURGE $report_mode + set ans [list] + if {$report_mode eq {error}} { + lappend ans [list ERR] + } + set tserr {} + if {$report_mode ne {silent}} { + set tserr [msg_unload {sticky3/1.0 } $msg]\n\n[msg_unload\ + sticky2/1.0 $reqmsg]\n\n[msg_unload sticky1/1.0 $reqmsg] + } + testouterr_cmd sh {purge} $ans $tserr + testouterr_cmd sh {purge -f} $ans $tserr + } +} +unsetenv_var MODULES_AUTO_HANDLING + + +# unload an alternative requirement while retaining the last necessary one +setenv_var TESTSUITE_STICKY purge_req +setenv_loaded_module [list sticky1/1.0 sticky1/2.0 sticky2/1.0] [list\ + $mp/sticky1/1.0 $mp/sticky1/2.0 $mp/sticky2/1.0] +setenv_var __MODULES_LMTAG sticky2/1.0&super-sticky +setenv_var __MODULES_LMPREREQ sticky2/1.0&sticky1/1.0|sticky1/2.0 +setenv_var MODULES_STICKY_PURGE silent +set ans [list] +lappend ans [list set _LMFILES_ $mp/sticky1/1.0:$mp/sticky2/1.0] +lappend ans [list set LOADEDMODULES sticky1/1.0:sticky2/1.0] +testouterr_cmd sh {purge} $ans {} +testouterr_cmd sh {purge -f} $ans {} +unsetenv_var TESTSUITE_STICKY + + +# super-sticky takes precedence regardless of dependent load order +setenv_loaded_module [list sticky1/1.0 sticky2/1.0 sticky3/1.0] [list\ + $mp/sticky1/1.0 $mp/sticky2/1.0 $mp/sticky3/1.0] +setenv_var __MODULES_LMPREREQ\ + sticky2/1.0&sticky1/1.0:sticky3/1.0&sticky1/1.0 +setenv_var MODULES_STICKY_PURGE warning +foreach {tag2 tag3 msg2 msg3} [list sticky super-sticky\ + $warn_stickyunload $warn_superstickyunload super-sticky sticky\ + $warn_superstickyunload $warn_stickyunload] { + setenv_var __MODULES_LMTAG sticky2/1.0&$tag2:sticky3/1.0&$tag3 + set abbrev2 [expr {$tag2 eq {sticky} ? {S} : {sS}}] + set abbrev3 [expr {$tag3 eq {sticky} ? {S} : {sS}}] + set tserr [msg_unload "sticky3/1.0 <$abbrev3>" $msg3]\n\n[msg_unload\ + "sticky2/1.0 <$abbrev2>" $msg2]\n\n[msg_unload sticky1/1.0\ + $warn_superstickyrequnload] + testouterr_cmd sh {purge} {} $tserr +} +unsetenv_var __MODULES_LMPREREQ + + # module both sticky and super-sticky setenv_var TESTSUITE_FPMOD_TAG sticky2 setenv_loaded_module [list foo/1.0 tag/1.0] [list $mp/foo/1.0 $mp/tag/1.0] From 65dda153ab9a210a474354024313186eb38d5afb Mon Sep 17 00:00:00 2001 From: Xavier Delaruelle Date: Sun, 20 Sep 2026 20:09:44 +0200 Subject: [PATCH 2/4] Keep sticky requirement of super-sticky module on forced purge On a forced purge, a sticky module required by a retained super-sticky module got a "forced unload" warning immediately followed by the error reporting its unload is skipped as a super-sticky requirement. The module stayed loaded, but the sticky check reported the forced unload before the requirement check retained the module. Check whether a module is required by a retained sticky module as part of the sticky check itself, prior reporting the forced unload of a sticky module. Rely on the existing sticky module test to determine if a sticky dependent is retained rather than duplicating this rule. Assisted-by: Claude:claude-fable-5-1 Signed-off-by: Xavier Delaruelle --- tcl/modeval.tcl | 19 +++++++--- tcl/subcmd.tcl.in | 5 --- .../modules.50-cmds/464-sticky_purge.exp | 36 +++++++++++++++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/tcl/modeval.tcl b/tcl/modeval.tcl index b527411d6..72a1efab5 100644 --- a/tcl/modeval.tcl +++ b/tcl/modeval.tcl @@ -965,7 +965,7 @@ proc getModuleStickyDependentTag {mod} { if {[isModuleTagged $dep_mod super-sticky 1]} { set sticky_tag super-sticky break - } elseif {![getState force] && [isModuleTagged $dep_mod sticky 1]} { + } elseif {[isModuleSticky $dep_mod]} { set sticky_tag sticky } } @@ -1166,10 +1166,8 @@ proc failOrSkipUnloadIfSticky {modname modfile} { set sticky_purge [expr {[getState commandname] eq {purge} ? [getConf\ sticky_purge] : {}}] - if {!$is_supersticky_not_reloading && $is_sticky_not_reloading &&\ - [getState force]} { - reportWarning [getStickyForcedUnloadMsg] - } elseif {$is_supersticky_not_reloading || $is_sticky_not_reloading} { + if {$is_supersticky_not_reloading || ($is_sticky_not_reloading &&\ + ![getState force])} { set msg [getStickyUnloadMsg [expr {$is_supersticky_not_reloading ?\ {super-sticky} : {sticky}}]] # no message if sticky_purge is set to silent @@ -1182,6 +1180,17 @@ proc failOrSkipUnloadIfSticky {modname modfile} { return 1 } + # on purge, module required by a retained sticky module is kept loaded, + # which prevails over the forced unload of a sticky module + if {$sticky_purge ne {} && [failOrSkipUnloadIfRequiredBySticky\ + $modname]} { + return 1 + } + + if {$is_sticky_not_reloading && [getState force]} { + reportWarning [getStickyForcedUnloadMsg] + } + return 0 } diff --git a/tcl/subcmd.tcl.in b/tcl/subcmd.tcl.in index 226f40aec..d53928dfe 100644 --- a/tcl/subcmd.tcl.in +++ b/tcl/subcmd.tcl.in @@ -1577,11 +1577,6 @@ proc cmdModuleUnload {context match auto force onlyureq args} { set prereq_list [getDependentLoadedModuleList [list $modname]] set prereq_loaded_list [getDependentLoadedModuleList [list $modname]\ 1 1 0 0] - if {[llength $prereq_loaded_list] &&\ - [getState commandname] eq {purge} &&\ - [failOrSkipUnloadIfRequiredBySticky $modname]} { - continue - } if {[llength $prereq_loaded_list] && (![getConf auto_handling] ||\ !$auto)} { # force mode should not affect if we only look for mods w/o dep diff --git a/testsuite/modules.50-cmds/464-sticky_purge.exp b/testsuite/modules.50-cmds/464-sticky_purge.exp index b939566dd..1ecec2884 100644 --- a/testsuite/modules.50-cmds/464-sticky_purge.exp +++ b/testsuite/modules.50-cmds/464-sticky_purge.exp @@ -218,6 +218,42 @@ foreach {tag2 tag3 msg2 msg3} [list sticky super-sticky\ unsetenv_var __MODULES_LMPREREQ +# sticky requirement of super-sticky module is kept on forced purge +setenv_var TESTSUITE_STICKY purge_req +setenv_loaded_module [list sticky1/1.0 sticky2/1.0] [list\ + $mp/sticky1/1.0 $mp/sticky2/1.0] +setenv_var __MODULES_LMTAG sticky1/1.0&sticky:sticky2/1.0&super-sticky +setenv_var __MODULES_LMPREREQ sticky2/1.0&sticky1/1.0|sticky1/2.0 +foreach {report_mode msg reqmsg stickymsg} [list error\ + $err_superstickyunload $err_superstickyrequnload $err_stickyunload\ + warning $warn_superstickyunload $warn_superstickyrequnload\ + $warn_stickyunload silent {} {} {}] { + setenv_var MODULES_STICKY_PURGE $report_mode + set ans [list] + if {$report_mode eq {error}} { + lappend ans [list ERR] + } + set tserr {} + if {$report_mode ne {silent}} { + set tserr [msg_unload {sticky2/1.0 } $msg]\n\n[msg_unload\ + {sticky1/1.0 } $stickymsg] + } + testouterr_cmd sh {purge} $ans $tserr + + # requirement is retained, so no forced unload warning + set tserr {} + if {$report_mode ne {silent}} { + set tserr [msg_unload {sticky2/1.0 } $msg]\n\n[msg_unload\ + {sticky1/1.0 } $reqmsg] + } + testouterr_cmd sh {purge -f} $ans $tserr +} + + +unsetenv_var __MODULES_LMPREREQ +unsetenv_var TESTSUITE_STICKY + + # module both sticky and super-sticky setenv_var TESTSUITE_FPMOD_TAG sticky2 setenv_loaded_module [list foo/1.0 tag/1.0] [list $mp/foo/1.0 $mp/tag/1.0] From a373ba3a8b1fc61b71754c5cdbb128f4b72a6d32 Mon Sep 17 00:00:00 2001 From: Xavier Delaruelle Date: Sun, 20 Sep 2026 20:10:15 +0200 Subject: [PATCH 3/4] Protect sticky module requirement whatever its load order on purge The requirement check on purge only looked at dependent modules loaded after the module to unload. A requirement loaded after its super-sticky dependent, for instance reloaded after a forced unload, was purged and left this dependent with a missing dependency, the very situation this protection aims to avoid. Look at dependent modules whatever their load order and drop the direct dependent pre-check that relied on load order too. Also cover the abort of the purge when the error raised for a retained requirement occurs with abort_on_error configured for this sub-command. Assisted-by: Claude:claude-fable-5-1 Signed-off-by: Xavier Delaruelle --- doc/source/design/sticky-modules.rst | 2 + tcl/modeval.tcl | 4 +- .../modules.50-cmds/464-sticky_purge.exp | 76 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/doc/source/design/sticky-modules.rst b/doc/source/design/sticky-modules.rst index 417bc50a1..04dcd4c07 100644 --- a/doc/source/design/sticky-modules.rst +++ b/doc/source/design/sticky-modules.rst @@ -52,6 +52,8 @@ Specification and super-sticky modules - Direct and indirect requirements are preserved, even without a sticky tag + - Requirements are preserved whatever their load order relative to their + dependent, as a requirement may be loaded after its dependent - Only strong requirements are preserved: an alternative requirement may be unloaded while another loaded module still satisfies it - A forced ``purge`` unloads sticky modules and can unload their diff --git a/tcl/modeval.tcl b/tcl/modeval.tcl index 72a1efab5..f0bcfabf8 100644 --- a/tcl/modeval.tcl +++ b/tcl/modeval.tcl @@ -961,7 +961,9 @@ proc isModuleSticky {mod} { proc getModuleStickyDependentTag {mod} { set sticky_tag {} - foreach dep_mod [getDependentLoadedModuleList [list $mod] 1 0 0 0] { + # look at dependents whatever their load order to also protect a + # requirement loaded after its sticky dependent + foreach dep_mod [getDependentLoadedModuleList [list $mod] 1 0 1 0] { if {[isModuleTagged $dep_mod super-sticky 1]} { set sticky_tag super-sticky break diff --git a/testsuite/modules.50-cmds/464-sticky_purge.exp b/testsuite/modules.50-cmds/464-sticky_purge.exp index 1ecec2884..7219fe4e7 100644 --- a/testsuite/modules.50-cmds/464-sticky_purge.exp +++ b/testsuite/modules.50-cmds/464-sticky_purge.exp @@ -250,6 +250,82 @@ foreach {report_mode msg reqmsg stickymsg} [list error\ } +# requirement loaded after its super-sticky dependent +setenv_loaded_module [list sticky2/1.0 sticky1/1.0] [list\ + $mp/sticky2/1.0 $mp/sticky1/1.0] +setenv_var __MODULES_LMTAG sticky2/1.0&super-sticky +foreach {report_mode msg reqmsg} [list error $err_superstickyunload\ + $err_superstickyrequnload warning $warn_superstickyunload\ + $warn_superstickyrequnload silent {} {}] { + setenv_var MODULES_STICKY_PURGE $report_mode + set ans [list] + if {$report_mode eq {error}} { + lappend ans [list ERR] + } + set tserr {} + if {$report_mode ne {silent}} { + set tserr [msg_unload sticky1/1.0 $reqmsg]\n\n[msg_unload\ + {sticky2/1.0 } $msg] + } + testouterr_cmd sh {purge} $ans $tserr + testouterr_cmd sh {purge -f} $ans $tserr +} + + +# requirement loaded after its sticky dependent +setenv_var __MODULES_LMTAG sticky2/1.0&sticky +foreach {report_mode msg reqmsg} [list error $err_stickyunload\ + $err_stickyrequnload warning $warn_stickyunload $warn_stickyrequnload\ + silent {} {}] { + setenv_var MODULES_STICKY_PURGE $report_mode + set ans [list] + if {$report_mode eq {error}} { + lappend ans [list ERR] + } + set tserr {} + if {$report_mode ne {silent}} { + set tserr [msg_unload sticky1/1.0 $reqmsg]\n\n[msg_unload\ + {sticky2/1.0 } $msg] + } + testouterr_cmd sh {purge} $ans $tserr + + set ans [list] + foreach var {__MODULES_LMPREREQ _LMFILES_ LOADEDMODULES __MODULES_LMTAG} { + lappend ans [list unset $var] + } + set tserr [msg_unload {sticky2/1.0 } $err_stickyunloadf] + testouterr_cmd sh {purge -f} $ans $tserr +} + + +# skipped requirement unload aborts purge if abort_on_error is configured +setenv_var MODULES_ABORT_ON_ERROR purge +setenv_loaded_module [list foo/1.0 sticky2/1.0 sticky1/1.0] [list\ + $mp/foo/1.0 $mp/sticky2/1.0 $mp/sticky1/1.0] +setenv_var __MODULES_LMTAG sticky2/1.0&super-sticky + +setenv_var MODULES_STICKY_PURGE error +set tserr [msg_unload sticky1/1.0 $err_superstickyrequnload] +testouterr_cmd sh {purge} ERR $tserr +# force mode disables abort behavior +set ans [list] +lappend ans [list set _LMFILES_ $mp/sticky2/1.0:$mp/sticky1/1.0] +lappend ans [list set LOADEDMODULES sticky2/1.0:sticky1/1.0] +lappend ans [list ERR] +set tserr [msg_unload sticky1/1.0 $err_superstickyrequnload]\n\n[msg_unload\ + {sticky2/1.0 } $err_superstickyunload] +testouterr_cmd sh {purge -f} $ans $tserr + +setenv_var MODULES_STICKY_PURGE warning +set ans [list] +lappend ans [list set _LMFILES_ $mp/sticky2/1.0:$mp/sticky1/1.0] +lappend ans [list set LOADEDMODULES sticky2/1.0:sticky1/1.0] +set tserr [msg_unload sticky1/1.0 $warn_superstickyrequnload]\n\n[msg_unload\ + {sticky2/1.0 } $warn_superstickyunload] +testouterr_cmd sh {purge} $ans $tserr +testouterr_cmd sh {purge -f} $ans $tserr + +unsetenv_var MODULES_ABORT_ON_ERROR unsetenv_var __MODULES_LMPREREQ unsetenv_var TESTSUITE_STICKY From a61af734440b483ef1fa9a0e79e9086bf8d26c29 Mon Sep 17 00:00:00 2001 From: Xavier Delaruelle Date: Sun, 20 Sep 2026 20:12:03 +0200 Subject: [PATCH 4/4] doc: fix NEWS placement and cookbook example of purge requirement Move the NEWS entry to the end of the 5.7.0 list, as entries follow the order changes landed. Report in the cookbook example the output of the env/base load, which auto-loads its requirements, and the auto-loaded tag these requirements then carry in the module list. Also restore the blank line removed from NEWS.rst, unrelated to this change. Assisted-by: Claude:claude-fable-5-1 Signed-off-by: Xavier Delaruelle --- NEWS.rst | 7 ++++--- doc/source/cookbook/sticky-modules.rst | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/NEWS.rst b/NEWS.rst index b7fe5c574..c8556dcf7 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -251,9 +251,6 @@ Modules 5.7.0 (not yet released) * Doc: add the :ref:`user-guide` document that explains a selection of useful but lesser known features through practical examples and common use cases. -* Keep non-sticky requirements of sticky modules loaded during a - :subcmd:`purge` and explicitly report why their unload is skipped according - to the :mconfig:`sticky_purge` configuration. (fix issue #582) * Init: fix command injection in Bash completion when module names contain shell meta-characters. Completion candidates were passed to ``compgen -W`` which evaluates command substitution syntax. (fix `CVE-2026-85013`_ found @@ -267,9 +264,13 @@ Modules 5.7.0 (not yet released) :mfcmd:`is-used` modulefile command or of the :subcmd:`unuse` sub-command is also resolved against the current working directory to recognize such entry whatever its form. +* Keep non-sticky requirements of sticky modules loaded during a + :subcmd:`purge` and explicitly report why their unload is skipped according + to the :mconfig:`sticky_purge` configuration. (fix issue #582) .. _CVE-2026-85013: https://github.com/envmodules/modules/security/advisories/GHSA-8hrw-p88g-qhmg + .. _5.6 release notes: Modules 5.6.2 (2026-09-07) diff --git a/doc/source/cookbook/sticky-modules.rst b/doc/source/cookbook/sticky-modules.rst index 182616d07..ed208eae8 100644 --- a/doc/source/cookbook/sticky-modules.rst +++ b/doc/source/cookbook/sticky-modules.rst @@ -200,6 +200,8 @@ module remains loaded: :ps:`$` module config sticky_purge warning :ps:`$` module load env/base + Loading :sgrhi:`env/base` + :sgrin:`Loading requirement`: core compiler :ps:`$` module purge Unloading :sgrhi:`env/base` :sgrwa:`WARNING`: Unload of super-sticky module skipped @@ -211,10 +213,10 @@ module remains loaded: :sgrwa:`WARNING`: Unload of super-sticky module requirement skipped :ps:`$` module list Currently Loaded Modulefiles: - 1) core 2) compiler 3) :sgrss:`env/base` + 1) :sgral:`core` 2) :sgral:`compiler` 3) :sgrss:`env/base` Key: - :sgrss:`super-sticky` + :sgral:`auto-loaded` :sgrss:`super-sticky` Both requirements remain loaded even though they are not sticky themselves. The same protection applies to indirect requirements and to a forced purge