8

I have a Bash script that is sourced. When this script is sourced, it runs a function in the Bash script. This function should terminate the script if a certain condition is matched. How can this be done without terminating the shell in which the script is sourced?

To be clear: I want the termination action to be completed by the function in the sourced shell script, not in the main body of the sourced shell script. The problems that I can see are that return simply returns from the function to the main of the script while exit 1 terminated the calling shell.

The following minimal example illustrates the problem:

main(){
    echo "starting test of environment..."
    ensure_environment
    echo "environment safe -- starting other procedures..."
}

ensure_environment(){
    if [ 1 == 1 ]; then
        echo "environment problemm -- terminating..."
        # exit 1 # <-- terminates calling shell
        return   # <-- returns only from function, not from sourced script
    fi
}

main
d3pd
  • 7,935
  • 24
  • 76
  • 127
  • 1
    Execute the script instead. This way, the `exit` will exit the subshell in which it is running. – fedorqui Nov 24 '15 at 13:11
  • 1
    Interesting. Could you give a little example to start with, that could be improved. E.g. one that terminates the shell. – Ludwig Schulze Nov 24 '15 at 13:16
  • You ask for something that's impossible. You have to restructure your code. – Karoly Horvath Nov 24 '15 at 13:20
  • 5
    @fedorqui The script is designed to set up a large number of environment variables, so I must source it I think. – d3pd Nov 24 '15 at 13:25
  • @LudwigSchulze As requested, I have added a full minimal example of a script that, when sourced, illustrates the problem. The `return` does not terminate the sourced script (only the function) and the `exit 1` terminates the calling shell. I need something in-between! – d3pd Nov 24 '15 at 13:26

7 Answers7

9

You can return from a sourced shell script. POSIX spec

So, while you can't return from the function directly to get what you want you can return from the main body of the script if your function returns non-zero (or some other agreed upon value).

For example:

$ cat foo.sh
f() {
    echo in f "$@"
}

e() {
    return 2
}

f 1
e
f 2
if ! e; then
    return
fi
f 3
$ . foo.sh
in f 1
in f 2
Etan Reisner
  • 77,877
  • 8
  • 106
  • 148
  • 1
    Thanks for your solution. I am aware of this approach. My purpose is to find a way of terminating the sourced script from within one of its functions because additional complexity that I can't really include in a minimal example makes it very desirable to centralise the termination procedure in a function. – d3pd Nov 24 '15 at 13:30
  • 1
    @d3pd You can't. Not without a sub-shell. It just isn't possible. You can keep any and all logic you want in the function with this though. You just need to call the function in an `if` or `ensure_environment || return` or something like that. .... You *might* be able to do something with a `RETURN` trap checking `$?` or whatever other variable actually. – Etan Reisner Nov 24 '15 at 13:33
3

This is a recipe how you can achieve your goal with your approach. I will not write your code for you, just describe how it can be done.

Your goal is to set/alter environment variables in the current bash shell by, effectively, sourcing a possibly complex shell script. Some component of this script may decide that execution of this sourced script should stop. What makes this complicated is that this decision is not necessarily top-level, but may be located in a nested function invocation. return, then, does not help, and exit would terminate the sourcing shell, which is not desired.

Your task is made easier by this statement of yours:

additional complexity that I can't really include in a minimal example makes it very desirable to centralise the termination procedure in a function.

This is how you do it:

Instead of sourcing your real script that decides which environment to set to what ("realscript.bash"), you source another script "ipcscript.bash".

ipcscript.bash will setup some interprocess communication. This may be a pipe on some extra file descriptor that you open with exec, it may be a temporary file, it may be something else.

ipcscript.bash will then start realscript.bash as a child process. That means, the environment changes that realscript.bash does first only affect the environment of that child process instance of bash. Starting realscript.bash as a childprocess, you gain the capability of terminating the execution at any nested level with exit without terminating the sourcing shell.

Your call to exit will live, as you write, in a centralised function that is called from any level when a decision is made to terminate execution. Your terminating function now needs, before exiting, to write the current environment to the IPC mechanism in a suitable format.

ipcscript.bash will read environment settings from the IPC mechanism and reproduce all settings in the process of the sourcing shell.

Ludwig Schulze
  • 2,155
  • 1
  • 17
  • 36
3

This is the solution I prefer (it comes with side effects, explained below):

#!/usr/bin/env bash
# force inheritance of ERR trap inside functions and subshells
shopt -s extdebug
# pick custom error code to force script end
CUSTOM_ERROR_CODE=13

# clear ERR trap and set a new one
trap - ERR
trap '[[ $? == "$CUSTOM_ERROR_CODE" ]] && echo "IN TRAP" && return $CUSTOM_ERROR_CODE 2>/dev/null;' ERR

# example function that triggers the trap, but does not end the script
function RETURN_ONE() { return 1; }
RETURN_ONE
echo "RETURNED ONE"

# example function that triggers the trap and ends the script
function RETURN_CUSTOM_ERROR_CODE() { return "$CUSTOM_ERROR_CODE"; }
# example function that indirectly calls the above function and returns success (0) after
function INDIRECT_RETURN_CUSTOM_ERROR_CODE() { RETURN_CUSTOM_ERROR_CODE; return 0; }
INDIRECT_RETURN_CUSTOM_ERROR_CODE
echo "RETURNED CUSTOM ERROR CODE"

# clear traps
trap - ERR
# disable inheritance of ERR trap inside functions and subshells
shopt -u extdebug

Output:

# source source_global_trap.sh
RETURNED ONE
IN TRAP
IN TRAP

Description: In short, the code sets a trap for ERR, but, inside the trap (as the first instruction) checks the return code against CUSTOM_ERROR_CODE and returns from the sourced script only for and with the value of CUSTOM_ERROR_CODE (arbitrarily chosen as 13 in this case). That means returning CUSTOM_ERROR_CODE anywhere (due to shopt -s extdebug, otherwise just the first level functions/commands) should produce the desired result of ending the script.

Side effects:

[01] The error code in CUSTOM_ERROR_CODE may be used by a command outside the script's control and can thus force the script to end without an explicit instruction to do so. This should be easily avoidable, but can cause some discomfort.

[02] Invoking shopt -s extdebug might cause unwanted behavior, depending on other factors in the script. Details here: https://www.gnu.org/software/bash/manual/html_node/The-Shopt-Builtin.html

[03] More importantly, this is the output of sourcing the script in a clean environment, three times, one after the other:

# exec bash

# source source_global_trap.sh
RETURNED ONE
IN TRAP
IN TRAP

# source source_global_trap.sh
RETURNED ONE
IN TRAP
IN TRAP
IN TRAP

# source source_global_trap.sh
RETURNED ONE
IN TRAP
IN TRAP
IN TRAP

I have several theories as to why this (extra trap call) occurs, but no conclusive explanation. It has not caused trouble during my tests, but any clarifications are strongly encouraged.

NULLx
  • 61
  • 5
2

How about this: Call everything through a simple wrapper, here "ocall", that maintains a global state, here "STILL_OK"

STILL_OK=true

ocall() {
    if $STILL_OK 
    then
       echo -- "$@" # this is for debugging, you can delete this line
       if "$@"
       then
          true 
       else
          STILL_OK=false
       fi
    fi
}

main(){
    ocall echo "starting test of environment..."
    ocall ensure_environment
    ocall echo "environment safe -- starting other procedures..."
}

ensure_environment(){
    if [ 1 == 1 ]; then
        ocall echo "environment problemm -- terminating..."
        # exit 1 # <-- terminates calling shell
        return 1  # <-- returns from sourced script but leaves sourcing shell running
    fi
}

ocall main
Ludwig Schulze
  • 2,155
  • 1
  • 17
  • 36
  • In order for `$@` to retain its properties, it should basically always be double-quoted. It doesn"t really matter here, but for me, it immediately sets off a newbie alarm, so you might want to fix it just for that reason. – tripleee Nov 24 '15 at 14:12
  • Right. I do avoid bash when I can use ruby. Corrected. – Ludwig Schulze Nov 24 '15 at 14:15
  • Nitpicking: `if $STILL_OK` is a security weakness. Better use `if [ "$STILL_OK" = "true" ]` even if it isn't as elegant. Otherwise in case you implement any path leading to this source line without setting `STILL_OK`, you will execute whatever is currently set in your environment for `STILL_OK`. Probably nothing but one never knows. – Alfe Nov 24 '15 at 14:37
2

It is not possible.

If you source a script it is (for the aspects concerned here) like entering each line one by one in the calling (sourcing) shell. You want to leave a scope (the sourced script) which does not exist, so it cannot be left.

The only way I can think of is by passing the exit-wish back to the calling function and checking for it:

main() {
    echo "starting test of environment..."
    [ "$(ensure_environment)" = "bailout" ] && return
    echo "environment safe -- starting other procedures..."
}

ensure_environment() {
    if [ 1 == 1 ]; then
        echo "bailout"
        return
    fi
}

main

What you ask for also is typically not possible in other languages. Normally each function can only terminate itself (by returning), not a wider defined scope outside of itself (like a script it resides in). An exception to this rule is exception handling using try/catch or similar.

Also consider this: If you source this script, the shell functions become known in the sourcing shell. So you can call them later again. Then there (again) is no surrounding scope the function could terminate.

Alfe
  • 56,346
  • 20
  • 107
  • 159
1

Sometimes I write a script that has handy functions that I want to use outside of the script. In this case, if the script is run, then it does its thing. But if the script is sourced, it just loads some functions into the sourcing shell. I use this form:

#!/bin/bash

# This function will be sourcable
foo() {
  echo hello world
}

# end if being sourced
if [[ $0 == bash ]]; then
  return
fi

# the rest of the script goes here
JeffG
  • 225
  • 2
  • 10
1

It is possible.

Do it like in any programming language and "raise an exception" which will propagate up the calling chain:

# cat r

set -u

err=

inner () {
   # we want to bailaout at this point:
   # so we cause -u to kick in:
   err="reason: some problem in 'inner' function"
   i=$error_occurred
   echo "will not be called"
}

inner1 () {
   echo before_inner
   inner
   echo "will not be called"
}


main () {
   echo before_inner1
   inner1
   echo "will not be called"
}

echo before_func
main || echo "even this is not shown"

# this *will* be called now, like typing next statement on the terminal:
echo after_main
echo "${err:-}" # if we failed

Test:

# echo $$
9655
# . r  || true
before_func
before_inner1
before_inner
bash: error_occurred: unbound variable
after_main
reason: some problem in 'inner' function
# echo $$
9655

You can silence the error via 2>/dev/null, clear

Red Pill
  • 511
  • 6
  • 15