2

What's the best way to include error cleanup logic for shell scripts?

Specifically, I have a script that does something like this:

mount a x
mount b y
setup_thing
mount c z
do_something
umount z
cleanup_thing
umount y
umount x

Any of the mounts, plus do_something itself, might fail. If, say, the mount c z fails, I would like the script to unmount the mounts that did succeed before exiting.

I don't want to have to repeat the cleanup code multiple times, and I don't want to wrap everything into an if-nest (because then you have to re-indent everything if you add an extra mount).

Is there a way to create a finally-stack or something, so the above could be written as:

set -e
mount a x && finally umount x
mount b y && finally umount y
setup_thing && finally cleanup_thing
mount c z && finally umount z
do_something

The idea being that once the "finally" command is registered it will execute (in reverse order) that command on exit, pass or fail -- but the things that didn't get set up successfully don't get cleaned up (because the cleanup might not be safe to execute if the setup failed).

Or to put it another way, if everything succeeds then it will execute umount z, cleanup_thing, umount y, umount x in that order -- same if only do_something fails. But if the mount b y fails then it will only execute umount x.

(And regardless it needs to exit with 0 or not-0 appropriately, although I don't need to preserve the exact exit code on failure.)

I know there's a trap builtin that lets you run a command on exit, but it only supports one command and replaces it each time. Is there a way to extend this into a stack like above? Or some other clean way to do it?

In error-handling patterns from Ye Olde C code you'd probably achieve something like this with x || goto cleanup_before_x but of course there's no goto.

Ideally something that works in (da)sh, although I'm ok with requiring bash if that simplifies things. (Perhaps using arrays?)

0

Revised to handle letting pre-existing mountpoints alone. Assuming the code is in this format, where the lines that might cause trouble have one command per line, which is either mount or umount:

mount a x
mount b y
setup_thing
mount c z
do_something
umount z
cleanup_thing
umount y
umount x

This kludge might work... copy this code to the 2nd line of the script:

mount | cut -d' ' -f3 | sed 's/.*/^u\?mount [^[:space:]]\* &$/' | 
  grep -v -f - $0 | sed 2d | exec sh -s -- "$@"

How it (theoretically) works:

  1. Use mount and cut to make a list of pre-existing mountpoints, to be left alone.
  2. Use sed to create a list of grep patterns that match those mount or umount commands which use any of those mountpoints.
  3. Use grep -v to search the current script for any lines that don't match the previous list of grep patterns. Leaving all the code that's fit to run.
  4. Use sed to remove the 2nd line, to prevent recursion.
  5. Use exec sh -s -- "$@" to run only that code, along with any command line arguments. Nothing past the exec line is run.

Test beforehand by changing exec to cat # and examining the output. If it looks good, put exec back in.

  • This relies on umount z being a no-op if it was not mounted -- what if the error was that it was already mounted, though? Or for some more complicated cleanup code that must only be executed if the corresponding setup code succeeded? – Miral Jul 13 '17 at 6:57
  • @Miral, Re "it was already mounted": sorry, I'm not quite following... As you say, if z is not mounted, umount z does no harm. If z is mounted, umount z unmounts it. Unless there's some third possible state, those two mutually exclusive conditions should be enough. If there is a third possibility, please elaborate. – agc Jul 13 '17 at 7:06
  • If mount z fails because z is already mounted, it will then be unmounted by the cleanup code, thus leaving the system in a different state than when it started. I want the cleanup to clean up the things that the script did without cleaning up the things it didn't do. – Miral Jul 13 '17 at 7:14
  • For another example, imagine setup code that renames a file and then creates another file in its place, with cleanup code that deletes the new file and moves the original back. It is not safe to run the cleanup code unless you know the setup code has succeeded. (You can make it almost safe by making the cleanup code conditionally check the current state, but that's error-prone too. Making it explicitly conditional on some variable set when the setup code succeeded would work, but would be very ugly.) – Miral Jul 13 '17 at 7:17
  • @Miral, OK I understand now. The Q itself should be improved to describe that condition more clearly. – agc Jul 13 '17 at 7:30
0

Personally, I would go for the nested if then, as it would be easier to read and maintain. However, if you have tons of nesting levels you could try something like this (I've prefixed with echo for testing):

#!/bin/bash
run1(){ echo mount a x;}
run2(){ echo mount b y;}
run3(){ echo setup_thing;}
run4(){ echo mount c z;}
run5(){ echo do_something;}

undo5(){ :;}
undo4(){ echo umount z;}
undo3(){ echo cleanup_thing;}
undo2(){ echo umount y;}
undo1(){ echo umount x;}

for i in {1..5}
do      run$i
        code=$?
        [ $code != 0 ] && break
done
let i=i-1
while [ $i -gt 0 ]
do      undo$i
        let i=i-1
done
exit $code

I've kept the run and undo functions in the order of your example, but you could gain by putting them closer to each other:

run1(){  mount a x;}
undo1(){ umount x;}
run2(){  mount b y;}
undo2(){ umount y;}
run3(){  setup_thing;}
undo3(){ cleanup_thing;}
...

Instead of numbering the functions 1,2,3... you could name the run functions anything and list the names in the order you want. Add a consistent prefix for the undo function to make it easier:

mnta(){ ... }
undomnta(){ ... }
mntb(){ ... }
undomntb(){ ... }

order='mnta mntb ...' toundo=
for i in $order
do      $i
        code=$?
        [ $code != 0 ] && break
        toundo="undo$i $toundo"
done
for i in $toundo
do  $i
done

Or you could indeed use trap, but set it up just once with trap mytrap exit, and use a global variable to hold what to cleanup and just add to it at each step: clean="1 $clean". The function mytrap would then just go through the values in $clean.

0

A common technique is to use a trap to undo everything when you're done. It needs to be idempotent, i.e. it should fail gracefully if some of the steps cannot be completed.

#!/bin/bash

clean_up=false
errorhandler () {
    umount z || true
    $clean_up && cleanup_thing
    umount y || true
    umount x
}
trap errorhandler ERR EXIT

mount a x
mount b y
setup_thing
clean_up=true
mount c z
do_something

Notice that the trap is also triggered on EXIT so it will execute before the script terminates normally, too; so you don't need to clean up explicitly at all.

The ERR pseudo-signal is a Bash extension, I believe. So this won't work in Ash/Dash/legacy Bourne shell etc.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.