diff mbox series

[v2] eval: Only restore exit status on exit/return

Message ID 20181202145332.snc5z6tvisnulc7q@gondor.apana.org.au (mailing list archive)
State Superseded
Delegated to: Herbert Xu
Headers show
Series [v2] eval: Only restore exit status on exit/return | expand

Commit Message

Herbert Xu Dec. 2, 2018, 2:53 p.m. UTC
On Sat, Dec 01, 2018 at 04:45:39PM +0800, Herbert Xu wrote:
> 
> Hmm, I think this breaks the following case which used to work:
> 
> 	dash -c 'trap "(:; exit) && echo BUG" EXIT; false'
> 
> I know this makes no sense but almost every other shell does it
> this way.

Here is a new patch that fixes the reported issue for both signal
traps and the EXIT trap.

---8<---
We unconditionally restore the saved status in exitreset, which
is incorrect as we only want to do it for exitcmd and returncmd.
This patch fixes the problem by introducing EXEND.

Reported-by: Martijn Dekker <martijn@inlv.org>
Fixes: da30b4b78769 ("[BUILTIN] Exit without arguments in a trap...")
Signed-off-by: Herbert Xu <herbert@gondor.apana.org.au>

Comments

Harald van Dijk Dec. 4, 2018, 11:57 p.m. UTC | #1
On 02/12/2018 14:53, Herbert Xu wrote:
> On Sat, Dec 01, 2018 at 04:45:39PM +0800, Herbert Xu wrote:
>> Hmm, I think this breaks the following case which used to work:
>>
>> 	dash -c 'trap "(:; exit) && echo BUG" EXIT; false'
>>
>> I know this makes no sense but almost every other shell does it
>> this way.

FWIW, I read this as you saying the behaviour is clearly wrong, and 
other shells doing it this way is the *only* reason you want to preserve 
it. This is contradicted by you in your reply to my other message, but I 
do not see how else to interpret this sentence.

> Here is a new patch that fixes the reported issue for both signal
> traps and the EXIT trap.

This has the benefit of fixing one other test case, a small modification 
from one of Martijn Dekker's:

   $SHELL -c 'trap "set -o bad@option" INT; kill -s INT $$' && echo BUG

Although the shell exited on the invalid option, it still reset the exit 
status to 0 because a trap handler was started, so BUG is printed. ksh 
is the only other shell I found to also print BUG.

In this test case, execution never reaches clear_traps() since no 
subshell is started. Any changes made there therefore cannot have any 
effect. Some other approach is needed. Distinguishing between the 
different ways of exiting like you're doing here does manage to get this 
right.

Still feels like it's more complicated than it should be though.
Harald van Dijk Dec. 6, 2018, 9:35 p.m. UTC | #2
On 04/12/2018 23:57, Harald van Dijk wrote:
> This has the benefit of fixing one other test case, a small modification 
> from one of Martijn Dekker's:
> 
>    $SHELL -c 'trap "set -o bad@option" INT; kill -s INT $$' && echo BUG

Another test case, one that still fails:

   trap exit INT
   trap 'true; kill -s INT $$' EXIT
   false

Here, inside the EXIT handler, "the command that executed immediately 
preceding the trap action" is `false`, but inside the INT handler, it's 
either `true` or `kill -s INT $$` (I think the latter, but it doesn't 
matter). dash treats it as if it were still `false`.
Harald van Dijk Dec. 8, 2018, 3:45 p.m. UTC | #3
On 06/12/2018 21:35, Harald van Dijk wrote:
> On 04/12/2018 23:57, Harald van Dijk wrote:
>> This has the benefit of fixing one other test case, a small 
>> modification from one of Martijn Dekker's:
>>
>>    $SHELL -c 'trap "set -o bad@option" INT; kill -s INT $$' && echo BUG
> 
> Another test case, one that still fails:
> 
>    trap exit INT
>    trap 'true; kill -s INT $$' EXIT
>    false
> 
> Here, inside the EXIT handler, "the command that executed immediately 
> preceding the trap action" is `false`, but inside the INT handler, it's 
> either `true` or `kill -s INT $$` (I think the latter, but it doesn't 
> matter). dash treats it as if it were still `false`.

The attached patch fixes this. In short, it assumes that if the EXIT 
action raises an exception, exitstatus will have been set appropriately, 
and modifies exitcmd() to set it. It also simplifies dotrap() by 
removing the unnecessary special handling of recursive calls.

It handles the following test cases, from this thread:

exec 2>/dev/null
$SHELL -c 'trap "(false) && echo BUG1" INT; kill -s INT $$'
$SHELL -c 'trap "(false) && echo BUG2" EXIT'
$SHELL -c 'trap "(false); echo \$?" EXIT'
$SHELL -c 'trap "(true) || echo BUG3" INT; false; kill -s INT $$'
$SHELL -c 'trap "(true) || echo BUG4" EXIT; false'
$SHELL -c 'trap "(true); echo \$?" EXIT; false'
$SHELL -c 'trap "(! :) && echo BUG5" EXIT'
$SHELL -c 'trap "(false) && echo BUG6" EXIT'
$SHELL -c 'trap "readonly foo=bar; (foo=baz) && echo BUG7" EXIT'
$SHELL -c 'trap "(set -o bad@option) && echo BUG8" EXIT'
$SHELL -c 'trap "set -o bad@option" EXIT' && echo BUG9
$SHELL -c 'trap "set -o bad@option" INT; kill -s INT $$' && echo BUG10
$SHELL -c 'trap exit INT; trap "true; kill -s INT $$" EXIT; false' || 
echo BUG11

It does not change the behaviour for these test cases, also from this 
thread:

$SHELL -c 'trap "(trap \"echo exit\" EXIT; :)" EXIT'
$SHELL -c 'trap "(:; exit) && echo exit" EXIT; false'

It can be combined with the other patches to also change the behaviour 
of those two.
--- a/src/eval.c
+++ b/src/eval.c
@@ -116,10 +116,7 @@ INCLUDE "eval.h"
 EXITRESET {
 	evalskip = 0;
 	loopnest = 0;
-	if (savestatus >= 0) {
-		exitstatus = savestatus;
-		savestatus = -1;
-	}
+	savestatus = -1;
 }
 #endif
 
--- a/src/main.c
+++ b/src/main.c
@@ -348,7 +348,9 @@ exitcmd(int argc, char **argv)
 		return 0;
 
 	if (argc > 1)
-		savestatus = number(argv[1]);
+		exitstatus = number(argv[1]);
+	else if (savestatus >= 0)
+		exitstatus = savestatus;
 
 	exraise(EXEXIT);
 	/* NOTREACHED */
--- a/src/trap.c
+++ b/src/trap.c
@@ -320,17 +320,14 @@ void dotrap(void)
 	char *p;
 	char *q;
 	int i;
-	int status, last_status;
+	int status;
 
 	if (!pending_sig)
 		return;
 
 	status = savestatus;
-	last_status = status;
-	if (likely(status < 0)) {
-		status = exitstatus;
-		savestatus = status;
-	}
+	savestatus = exitstatus;
+
 	pending_sig = 0;
 	barrier();
 
@@ -350,10 +347,10 @@ void dotrap(void)
 			continue;
 		evalstring(p, 0);
 		if (evalskip != SKIPFUNC)
-			exitstatus = status;
+			exitstatus = savestatus;
 	}
 
-	savestatus = last_status;
+	savestatus = status;
 }
 
 
@@ -390,8 +387,10 @@ exitshell(void)
 
 	savestatus = exitstatus;
 	TRACE(("pid %d, exitshell(%d)\n", getpid(), savestatus));
-	if (setjmp(loc.loc))
+	if (setjmp(loc.loc)) {
+		savestatus = exitstatus;
 		goto out;
+	}
 	handler = &loc;
 	if ((p = trap[0])) {
 		trap[0] = NULL;
Herbert Xu Dec. 14, 2018, 3:10 a.m. UTC | #4
On Sat, Dec 08, 2018 at 03:45:11PM +0000, Harald van Dijk wrote:
>
> --- a/src/eval.c
> +++ b/src/eval.c
> @@ -116,10 +116,7 @@ INCLUDE "eval.h"
>  EXITRESET {
>  	evalskip = 0;
>  	loopnest = 0;
> -	if (savestatus >= 0) {
> -		exitstatus = savestatus;
> -		savestatus = -1;
> -	}
> +	savestatus = -1;
>  }
>  #endif

The reason this is needed is to support a naked return.  With
your patch a naked return would either have to always return the
saved status or the new status.  Neither of which is what we
want.

Please refer to

commit 70c16dd30d4cf824aa895e9f6c095fec741c65a8
Author: Herbert Xu <herbert@gondor.apana.org.au>
Date:   Mon Oct 6 21:51:26 2014 +0800

    [BUILTIN] Return without arguments in a trap should use status outside traps

Thanks,
Herbert Xu Dec. 14, 2018, 3:12 a.m. UTC | #5
On Thu, Dec 06, 2018 at 09:35:47PM +0000, Harald van Dijk wrote:
> On 04/12/2018 23:57, Harald van Dijk wrote:
> > This has the benefit of fixing one other test case, a small modification
> > from one of Martijn Dekker's:
> > 
> >    $SHELL -c 'trap "set -o bad@option" INT; kill -s INT $$' && echo BUG
> 
> Another test case, one that still fails:
> 
>   trap exit INT
>   trap 'true; kill -s INT $$' EXIT
>   false
> 
> Here, inside the EXIT handler, "the command that executed immediately
> preceding the trap action" is `false`, but inside the INT handler, it's
> either `true` or `kill -s INT $$` (I think the latter, but it doesn't
> matter). dash treats it as if it were still `false`.

I think this makes sense.  The EXIT trap trumps whatever happens
inside it.  FWIW ksh/mksh both do the same thing.

Cheers,
Harald van Dijk Dec. 14, 2018, 8:02 a.m. UTC | #6
On 14/12/2018 03:10, Herbert Xu wrote:
> On Sat, Dec 08, 2018 at 03:45:11PM +0000, Harald van Dijk wrote:
>>
>> --- a/src/eval.c
>> +++ b/src/eval.c
>> @@ -116,10 +116,7 @@ INCLUDE "eval.h"
>>   EXITRESET {
>>   	evalskip = 0;
>>   	loopnest = 0;
>> -	if (savestatus >= 0) {
>> -		exitstatus = savestatus;
>> -		savestatus = -1;
>> -	}
>> +	savestatus = -1;
>>   }
>>   #endif
> 
> The reason this is needed is to support a naked return.  With
> your patch a naked return would either have to always return the
> saved status or the new status.  Neither of which is what we
> want.

I actually saw the commit you referenced already and tested that with my 
patch before sending. This is how I tested it:

Returns 1:

   f() {
     false; return
   }
   f

Returns 0:

   f() {
     trap "false; return" USR1
     kill -USR1 $$
   }
   f

The former picks up the status of the false command, the latter of the 
kill command. This works because although returncmd() only looks at 
exitstatus, so returns the wrong value, in the no-argument version it 
sets skip to SKIPFUNCDEF, causing dotrap() to restore the original 
exitstatus value, whereas in the version that does take arguments, skip 
is set to SKIPFUNC, causing dotrap() to leave exitstatus alone.
Harald van Dijk Dec. 14, 2018, 8:37 a.m. UTC | #7
On 14/12/2018 08:02, Harald van Dijk wrote:
> On 14/12/2018 03:10, Herbert Xu wrote:
>> On Sat, Dec 08, 2018 at 03:45:11PM +0000, Harald van Dijk wrote:
>>>
>>> --- a/src/eval.c
>>> +++ b/src/eval.c
>>> @@ -116,10 +116,7 @@ INCLUDE "eval.h"
>>>   EXITRESET {
>>>       evalskip = 0;
>>>       loopnest = 0;
>>> -    if (savestatus >= 0) {
>>> -        exitstatus = savestatus;
>>> -        savestatus = -1;
>>> -    }
>>> +    savestatus = -1;
>>>   }
>>>   #endif
>>
>> The reason this is needed is to support a naked return.  With
>> your patch a naked return would either have to always return the
>> saved status or the new status.  Neither of which is what we
>> want.
> 
> I actually saw the commit you referenced already and tested that with my 
> patch before sending. This is how I tested it:
> 
> Returns 1:
> 
>    f() {
>      false; return
>    }
>    f
> 
> Returns 0:
> 
>    f() {
>      trap "false; return" USR1
>      kill -USR1 $$
>    }
>    f
> 
> The former picks up the status of the false command, the latter of the 
> kill command. This works because although returncmd() only looks at 
> exitstatus, so returns the wrong value, in the no-argument version it 
> sets skip to SKIPFUNCDEF, causing dotrap() to restore the original 
> exitstatus value, whereas in the version that does take arguments, skip 
> is set to SKIPFUNC, causing dotrap() to leave exitstatus alone.

Which is exactly how that particular commit was supposed to work in the 
first place, so perhaps it's simpler to put it as "in these tests, 
changes to exitreset() do not cause the test to break because 
exitreset() is never called at the wrong time".

If there's some test that breaks, it would need to be one where the 
original exitstatus needs to be restored by exitreset(), *and* the 
original exitstatus is not already restored by dotrap().

The original exitstatus is not restored by dotrap() only if evalskip == 
SKIPFUNC (i.e. return with argument), in which case it shouldn't be 
restored, or it exits with an exception. And exitreset() is only called 
when control gets back to main() via an exception.

So you'd need an example where an exception is raised after the return 
command successfully completes without arguments, yet before the 
function the return command is in (if any) actually returns, where that 
exception would then reach main(). I struggle to come up with such an 
example.
Herbert Xu Dec. 14, 2018, 9:16 a.m. UTC | #8
On Fri, Dec 14, 2018 at 08:02:04AM +0000, Harald van Dijk wrote:
> On 14/12/2018 03:10, Herbert Xu wrote:
>
> > The reason this is needed is to support a naked return.  With
> > your patch a naked return would either have to always return the
> > saved status or the new status.  Neither of which is what we
> > want.
> 
> I actually saw the commit you referenced already and tested that with my
> patch before sending. This is how I tested it:
> 
> Returns 1:
> 
>   f() {
>     false; return
>   }
>   f

That's not a naked return.  A naked return is one used outside of
a function.  I know this is unspecified under POSIX, but for dash
this has a specific meaning and that is the return is equivalent
to exit.

Cheers,
Harald van Dijk Dec. 14, 2018, 9:37 a.m. UTC | #9
On 14/12/2018 09:16, Herbert Xu wrote:
> On Fri, Dec 14, 2018 at 08:02:04AM +0000, Harald van Dijk wrote:
>> On 14/12/2018 03:10, Herbert Xu wrote:
>>
>>> The reason this is needed is to support a naked return.  With
>>> your patch a naked return would either have to always return the
>>> saved status or the new status.  Neither of which is what we
>>> want.
>>
>> I actually saw the commit you referenced already and tested that with my
>> patch before sending. This is how I tested it:
>>
>> Returns 1:
>>
>>    f() {
>>      false; return
>>    }
>>    f
> 
> That's not a naked return.  A naked return is one used outside of
> a function.  I know this is unspecified under POSIX, but for dash
> this has a specific meaning and that is the return is equivalent
> to exit.

The commit you referred to specifically mentioned changing the behaviour 
to conform to POSIX requirements, and now it's actually about an 
extension? Still, that works as well, doesn't it? The control flow is 
basically the same so the logic in my other message applies. returncmd() 
doesn't use exceptions, so you get to dotrap() which already resets 
exitstatus if necessary.

Test cases:

Returns 0 (status of previous kill command):

   trap "false; return" USR1
   kill -USR1 $$

Returns 1 (status specified in return command):

   trap "return 1" USR1
   kill -USR1 $$

Kills itself with SIGINT (status of cat command) after printing "bye":

   trap "echo bye; return" INT
   cat; :
   <press Ctrl-C>

Or is this about observing return's status in a trap action?

Prints status=0 and exits with status 0:

   trap "echo status=\$?; false; exit" EXIT
   return

Prints status=1 and exits with status 1:

   trap "echo status=\$?; exit" EXIT
   return 1
Herbert Xu Dec. 14, 2018, 9:47 a.m. UTC | #10
On Fri, Dec 14, 2018 at 09:37:09AM +0000, Harald van Dijk wrote:
>
> Still, that works as well, doesn't it? The control flow is basically the
> same so the logic in my other message applies. returncmd() doesn't use
> exceptions, so you get to dotrap() which already resets exitstatus if
> necessary.

returncmd itself doesn't jump up but it may be part of a subshell
which would execute a jump to the top as part of the EV_EXIT
optimisation.  That's where you'd need to restore the exit status.

Cheers,
Harald van Dijk Dec. 14, 2018, 10:07 a.m. UTC | #11
On 14/12/2018 09:47, Herbert Xu wrote:
> On Fri, Dec 14, 2018 at 09:37:09AM +0000, Harald van Dijk wrote:
>>
>> Still, that works as well, doesn't it? The control flow is basically the
>> same so the logic in my other message applies. returncmd() doesn't use
>> exceptions, so you get to dotrap() which already resets exitstatus if
>> necessary.
> 
> returncmd itself doesn't jump up but it may be part of a subshell
> which would execute a jump to the top as part of the EV_EXIT
> optimisation.  That's where you'd need to restore the exit status.

Ah, okay, so for something like

   trap 'false; (return); echo $?' EXIT

you want to remember that it's executed as part of a trap action and 
print 0, not 1.

I actually want that to print 1, so for me it's not a problem. However, 
my patch can be trivially modified to handle that: just have returncmd() 
check savestatus the same way exitcmd() does. I think that also allows 
merging SKIPFUNC and SKIPFUNCDEF, since you can then assume exitstatus 
was always set appropriately, so it allows for some further reduction in 
code.
Harald van Dijk Dec. 14, 2018, 10:54 a.m. UTC | #12
On 14/12/2018 10:07, Harald van Dijk wrote:
> On 14/12/2018 09:47, Herbert Xu wrote:
>> On Fri, Dec 14, 2018 at 09:37:09AM +0000, Harald van Dijk wrote:
>>>
>>> Still, that works as well, doesn't it? The control flow is basically the
>>> same so the logic in my other message applies. returncmd() doesn't use
>>> exceptions, so you get to dotrap() which already resets exitstatus if
>>> necessary.
>>
>> returncmd itself doesn't jump up but it may be part of a subshell
>> which would execute a jump to the top as part of the EV_EXIT
>> optimisation.  That's where you'd need to restore the exit status.
> 
> Ah, okay, so for something like
> 
>    trap 'false; (return); echo $?' EXIT
> 
> you want to remember that it's executed as part of a trap action and 
> print 0, not 1.
> 
> I actually want that to print 1, so for me it's not a problem. However, 
> my patch can be trivially modified to handle that: just have returncmd() 
> check savestatus the same way exitcmd() does. I think that also allows 
> merging SKIPFUNC and SKIPFUNCDEF, since you can then assume exitstatus 
> was always set appropriately, so it allows for some further reduction in 
> code.

I think this is needed regardless to fix a bug as well:

   trap 'f() { false; return; }; f; echo $?' EXIT

Other shells are evenly divided, but POSIX clearly requires this to 
print 0 (which is what bash/ksh/mksh/yash do): calling a function does 
not reset any trap information, so this return statement must be 
considered to execute in a trap action. It currently prints 1 (also seen 
in bosh/pdksh/posh/zsh). With my suggested changes, it prints 0.
diff mbox series

Patch

diff --git a/src/error.h b/src/error.h
index 9630b56..94e30a2 100644
--- a/src/error.h
+++ b/src/error.h
@@ -66,7 +66,8 @@  extern int exception;
 /* exceptions */
 #define EXINT 0		/* SIGINT received */
 #define EXERROR 1	/* a generic error */
-#define EXEXIT 4	/* exit the shell */
+#define EXEND 3		/* exit the shell */
+#define EXEXIT 4	/* exit the shell via exitcmd */
 
 
 /*
diff --git a/src/eval.c b/src/eval.c
index 546ee1b..3cea2a3 100644
--- a/src/eval.c
+++ b/src/eval.c
@@ -114,12 +114,13 @@  STATIC const struct builtincmd bltin = {
 INCLUDE "eval.h"
 
 EXITRESET {
-	evalskip = 0;
-	loopnest = 0;
 	if (savestatus >= 0) {
-		exitstatus = savestatus;
+		if (exception == EXEXIT || evalskip == SKIPFUNCDEF)
+			exitstatus = savestatus;
 		savestatus = -1;
 	}
+	evalskip = 0;
+	loopnest = 0;
 }
 #endif
 
@@ -314,7 +315,7 @@  out:
 
 	if (flags & EV_EXIT) {
 exexit:
-		exraise(EXEXIT);
+		exraise(EXEND);
 	}
 
 	return exitstatus;
diff --git a/src/exec.c b/src/exec.c
index 9d0215a..87354d4 100644
--- a/src/exec.c
+++ b/src/exec.c
@@ -143,7 +143,7 @@  shellexec(char **argv, const char *path, int idx)
 	exitstatus = exerrno;
 	TRACE(("shellexec failed for %s, errno %d, suppressint %d\n",
 		argv[0], e, suppressint ));
-	exerror(EXEXIT, "%s: %s", argv[0], errmsg(e, E_EXEC));
+	exerror(EXEND, "%s: %s", argv[0], errmsg(e, E_EXEC));
 	/* NOTREACHED */
 }
 
diff --git a/src/main.c b/src/main.c
index 6d53e00..6b3a090 100644
--- a/src/main.c
+++ b/src/main.c
@@ -111,7 +111,7 @@  main(int argc, char **argv)
 		e = exception;
 
 		s = state;
-		if (e == EXEXIT || s == 0 || iflag == 0 || shlvl)
+		if (e == EXEND || e == EXEXIT || s == 0 || iflag == 0 || shlvl)
 			exitshell();
 
 		reset();
diff --git a/src/trap.c b/src/trap.c
index ab0ecd4..64917fc 100644
--- a/src/trap.c
+++ b/src/trap.c
@@ -397,8 +397,10 @@  exitshell(void)
 		trap[0] = NULL;
 		evalskip = 0;
 		evalstring(p, 0);
+		evalskip = SKIPFUNCDEF;
 	}
 out:
+	exitreset();
 	/*
 	 * Disable job control so that whoever had the foreground before we
 	 * started can get it back.
@@ -406,7 +408,7 @@  out:
 	if (likely(!setjmp(loc.loc)))
 		setjobctl(0);
 	flushall();
-	_exit(savestatus);
+	_exit(exitstatus);
 	/* NOTREACHED */
 }