bug-bash
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

variable set in exec'ing shell cannot be unset by child shell


From: Ti Strga
Subject: variable set in exec'ing shell cannot be unset by child shell
Date: Fri, 13 Oct 2023 13:02:30 -0400

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.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]