First off, I have a feeling that GMail is going to garble the line wrapping in this message; I cannot get it to stop being "helpful". Apologies if that happens.
I've encountered some behavior that I cannot find described anywhere in the man page, and I'm hoping to learn whether it's a bug (it seems like unintended behavior) or just a quirk for hysterical raisins. If it's the latter then I'm also hoping there's a BASH_COMPAT level that might adjust the behavior, although I'll state right now that I have no idea whether previous versions behaved any differently. The summary is that if a parameter is set specifically for a '.'/'source' command, and the source'd file calls 'exec' to run another script, then that exec'd script cannot unset the parameter; if we want that parameter to not be present in the exec'd script, then the source'd file must do the unsetting prior to exec. We're running this... $ declare -p BASH_VERSINFO declare -ar BASH_VERSINFO=([0]="5" [1]="2" [2]="15" [3]="3" [4]="release" [5]="x86_64-pc-cygwin") ...although the platform [5] doesn't seem to matter; the same behavior was reported to me on Linux as well as what I'm observing on Cygwin. I did not have a chance to verify the Linux behavior firsthand. === Background (or, I Promise This Isn't Just Code Golf) The example reproduction here is a calling script "outer" sourcing "inner.sh". The real world situation is that "inner.sh" is a small library of shell functions and environment variable setup for our workflow, and multiple top-level scripts each '.' that library. The games here with exec are to support scripts that might be running for a long time. For those we want the script to make a temporary copy of itself and exec the temp copy, so that potential updates to the installed scripts don't hose up the long-running shell when it suddenly reads from a different point in the script.[*] The way it's implemented, the author of the top-level script can simply set a parameter when sourcing the library; the library makes the copy and performs the exec. When the copy sets the same parameter and sources the library, the library detects the cloning and will not keep doing it. (The library also fixes up what gets reported as "name of current script" for error messages and whatnot, but none of that is shown here as it doesn't change the weird behavior.) [*] Alternatively, there's the trick about putting the entire script contents inside a compound statement to force the parser to read it all, but that just makes the script harder for a human to read. Copy-and-exec makes the top-level scripts cleaner IMHO. The kicker is that the parameters that trigger all this behavior must be unset before going on with the remainder of the library and back to the calling script. If not, then anytime a "cloned" script might call any other script, that will be cloned as well even if its author did not write anything saying to do that. (And in a couple cases, the scripts actually start an interactive subshell; if the parameters get exported to there, then "CLONE ALL THE THINGS" behavior just keeps propagating through the scripts. Hilarity ensues.) === Reproduction $ cd /tmp $ cat outer #!/bin/bash # This is a top-level script. These parameters should not exist when # we're invoking this ourselves. echo $0 sanity checking inherited environment, OUTSIDE is $OUTSIDE # Real code does this # PATH=/where/scripts/normally/go:$PATH OUTSIDE=clone . inner.sh # with no relative path in the sourced filename. Doesn't affect the # weird behavior here, but figured the subject might come up. OUTSIDE=clone . ./inner.sh echo $0 having returned from sourcing file, OUTSIDE is $OUTSIDE $ cat inner.sh # If the calling script sets OUTSIDE, that indicates to perform the copy # and exec. We set INSIDE to indicate that we have in fact already done # this; the real world scripts also pass some data to the clone, including # how the clone can clean up after itself. echo == $0 beginning sourced file, OUTSIDE is $OUTSIDE and INSIDE is $INSIDE if [[ -v OUTSIDE ]]; then if [[ -v INSIDE ]]; then echo == $0 inside cloned copy, OUTSIDE is $OUTSIDE and INSIDE is $INSIDE unset OUTSIDE INSIDE # this doesn't work on OUTSIDE, but should? echo == $0 return from unset is $?, now OUTSIDE is $OUTSIDE and INSIDE is $INSIDE # from here we fall through to the rest of the file else echo == $0 not inside, about to clone cp "$0" ./COPY_OF_OUTER chmod 0700 ./COPY_OF_OUTER #unset OUTSIDE # THIS LINE IS CRUCIAL INSIDE="additional data" exec ./COPY_OF_OUTER "$@" fi fi echo == $0 finishing sourced file, OUTSIDE is $OUTSIDE and INSIDE is $INSIDE # and this is where the remainder of the shell library goes Originally, we have the code as shown above. The inner.sh sets INSIDE, execs the copy, the copy calls 'unset' on both the triggering params -- and 'unset' returns zero, apparently successful. Unfortunately, OUTSIDE has gone into Rasputin mode, immune to being unset and surviving all the way to the "having returned" point (where it can start to affect other scripts): $ ./outer ./outer sanity checking inherited environment, OUTSIDE is == ./outer beginning sourced file, OUTSIDE is clone and INSIDE is == ./outer not inside, about to clone /tmp/COPY_OF_OUTER sanity checking inherited environment, OUTSIDE is clone == /tmp/COPY_OF_OUTER beginning sourced file, OUTSIDE is clone and INSIDE is additional data == /tmp/COPY_OF_OUTER inside cloned copy, OUTSIDE is clone and INSIDE is additional data == /tmp/COPY_OF_OUTER return from unset is 0, now OUTSIDE is clone and INSIDE is == /tmp/COPY_OF_OUTER finishing sourced file, OUTSIDE is clone and INSIDE is /tmp/COPY_OF_OUTER having returned from sourcing file, OUTSIDE is clone The workaround is to uncomment the "THIS LINE IS CRUCIAL", clearing OUTSIDE before exec'ing. It will get re-assigned by the clone, but with INSIDE also set, the control flow will notice and unset them both, this time actually succeeding: $ ./outer ./outer sanity checking inherited environment, OUTSIDE is == ./outer beginning sourced file, OUTSIDE is clone and INSIDE is == ./outer not inside, about to clone /tmp/COPY_OF_OUTER sanity checking inherited environment, OUTSIDE is == /tmp/COPY_OF_OUTER beginning sourced file, OUTSIDE is clone and INSIDE is additional data == /tmp/COPY_OF_OUTER inside cloned copy, OUTSIDE is clone and INSIDE is additional data == /tmp/COPY_OF_OUTER return from unset is 0, now OUTSIDE is and INSIDE is == /tmp/COPY_OF_OUTER finishing sourced file, OUTSIDE is and INSIDE is /tmp/COPY_OF_OUTER having returned from sourcing file, OUTSIDE is Here we get the desired behavior, in that control flow in the cloned copy reaches the "having returned" with none of the parameters set. So what's up with 'unset OUTSIDE' not actually doing that? We've got a workaround, but I'm still confused as to what went wrong to begin with.