Skip to content

Improve ergonomics and usefulness of es -e. #73

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: master
Choose a base branch
from

Conversation

jpco
Copy link
Collaborator

@jpco jpco commented Dec 29, 2023

This pull request is meant to improve the usefulness of es' -e flag. It does a few things:

  1. Fixes the bug mentioned at Does anybody here use `es -e`? #70 (comment)

This makes it so that exits-on-false only occur when $&exitonfalse is actually in the call stack. With this, REPL code, and functions called from the REPL like %parse or %write-history, can't cause the shell to implode, and so are much easier to write.

  1. Makes it so assignments can't trigger exits-on-false (unless you do something to intentionally trigger it like result <={var = value}).

The implementation seems a little sketchy to me, but as far as I have been able to test, it works. Before this PR, every one of the following lines causes the shell to exit; with it, none of them do.

; var = value one
; {var = value two}
; fn var {var = value three}
; var
  1. Turns exits-on-false into exceptions-on-false.

Instead of just calling exit(2), throw a false exception along with the triggering value. I strongly feel that using an exception is the Right Thing:

  • most other ways to cause the shell to exit do so via exceptions -- including the exit command itself! It is unpleasantly surprising to lack the ability to handle and clean up after errors just for this.

  • exit-on-false is essentially a crappy imitation of exceptions in shells that lack them. Es actually has exceptions, so it should use them for this like it does for internal errors, signals, and the like. Exceptions are a far more powerful mechanism for error handling than just killing the process.

  • the "nuclear option" of exiting on false requires that when commands are run inside constructs that actually want to use their return values, like if or <=, the exit-on-false behavior has to be "switched off" in order that the shell doesn't just go poof, which leads to confusing behavior; see (More) issues with es -e #176. Exceptions, on the other hand, can simply be handled by the if and <= constructs directly, such that actually generating the exit-on-false exceptions doesn't need to be "toggled" off and on during the execution of a script.

I picked a new exception false because it enables if and <= to catch these particular exceptions without also messing with errors or any other exceptions.

I would rename %exit-on-false and $&exitonfalse to %throw-on-false and $&throwonfalse if not for maintaining backwards compatibility.

If the user doesn't go out of their way catch false in a script, then the shell will quietly exit. In an interactive context...

  1. %interactive-loop catches the false exception, complains, and then re-prompts.

This is a fair bit friendlier than silently exiting -- not that it's very typical to use -e for an interactive session.

jpco added 5 commits November 30, 2023 20:20
… catch, complain, and retry); and limit the exit-on-false behavior to only be triggered in the context of %exit-on-false.
This feels like a hack, but it seems to work, and I can't come up with a way to break it right now.
@jpco
Copy link
Collaborator Author

jpco commented Dec 29, 2023

A thought that has just occurred to me, relevant for exception-based exit-on-false: Should false-exiting commands also cause an exception inside an exception handler? For example, if the following script is sourced with -e, should "finished the handler" be echoed or not?

catch @ e {
  result maybe throw an error here?
  echo finished the handler
} {
  result throw an error here
}

@memreflect
Copy link
Contributor

Regarding your last suggestion, i would expect anything inside an exception handler that results in a false value to throw another exception when -e is in use.
This would be expected behavior to me, but it certainly makes writing an exception handler more difficult when -e is used, particularly because catch @ {false} {result foo} will still trigger a false exception.

I haven't encountered any trouble yet, but why does a false exception cause the shell to exit in %interactive-loop?
In fact, the only exceptions caught by %interactive-loop that should cause the shell to exit should be the exit and eof exceptions, so calling %dispatch after catching an error exception is wrong too...
I can understand abandoning further evaluation and the shell complaining about the exception, but there's no need for an interactive session to die just because something went wrong is there?
%batch-loop should exit if an exception is uncaught, but not %interactive-loop, right?

Or am i missing something glaringly obvious as usual? :-P

@jpco
Copy link
Collaborator Author

jpco commented Jan 22, 2024

This would be expected behavior to me, but it certainly makes writing an exception handler more difficult when -e is used, particularly because catch @ {false} {result foo} will still trigger a false exception.

Yikes. Though I suppose that things like

; result = <={catch @ {false} {result foo}}

still work? This feels to me like a tradeoff between "more-surprising and more-useful" behavior and "less-surprising and less-useful" behavior. It's hard for me to judge the tradeoff without some actual experience. So I guess initially I'd say let's go with the less-surprising behavior, and if it ends up burdensome in practice, it can be revisited later.

Or am i missing something glaringly obvious as usual? :-P

You're missing nothing at all, and I actually prefer a complain-and-retry behavior in %interactive-loop for false results, and I initially implemented it that way -- but I got nervous about the idea of changing the "exit on false" flag to not actually cause the shell to, well, exit on false, so I changed it in a4f3b6d. I'd happily revert that commit, though.

@jpco
Copy link
Collaborator Author

jpco commented Sep 12, 2024

so calling %dispatch after catching an error exception is wrong too...

I only just got this (somehow I don't remember reading it at all before). You're 100% right, I missed that case. Though now that I look at it, I'm not entirely sure what the point of the $fn-%dispatch false line is in its current form, so I'm not sure exactly what the right behavior should be. Is it just there to make sure a shell invoked with -e will die on an error exception?

jpco added a commit to jpco/es-shell that referenced this pull request Sep 14, 2024
This causes %exit-on-false, as part of %dispatch, to be the actual trigger for the exit-on-false behavior.  The implementation context strongly implies that the current behavior is a bug.  (Why would we use %exit-on-false, whose only job is to set eval_exitonfalse to 1, and also just leave eval_exitonfalse as 1 ourselves?)

This also makes using `es -e` with any kind of overriden REPL much easier, because %prompt and other REPL hooks can no longer trigger the exit-on-false behavior.

This is one part of wryun#73, but because this part is almost definitely just a bug, while the rest is more about design improvements, I am merging this part more unilaterally.
jpco added a commit that referenced this pull request Sep 14, 2024
This causes %exit-on-false, as part of %dispatch, to be the actual trigger for the exit-on-false behavior.  The implementation context strongly implies that the current behavior is a bug.  (Why would we use %exit-on-false, whose only job is to set eval_exitonfalse to 1, and also just leave eval_exitonfalse as 1 ourselves?)

This also makes using `es -e` with any kind of overriden REPL much easier, because %prompt and other REPL hooks can no longer trigger the exit-on-false behavior.

This is one part of #73, but because this part is almost definitely just a bug, while the rest is more about design improvements, I am merging this part more unilaterally.
@jpco jpco mentioned this pull request Mar 11, 2025
@jpco
Copy link
Collaborator Author

jpco commented Apr 5, 2025

Now this PR also fixes #176.

Essentially we no longer "turn off" -e inside of if tests and <=; instead, those constructs simply catch any false exceptions generated from their commands. This leads to more predictable and regular behavior, I think. For example, if you have a function

fn falsish {
    false
    echo 'after false'
}

you no longer have whether you call echo 'after false', and the truthiness of the function, vary depending on whether you call

falsish

or

if falsish {echo it was true}

(note that in the current shell at HEAD, the body of that if will actually run!)

It also makes it so these two now, consistently, don't stop on false:

; echo `{echo a; false; echo b}
a b
; cat <{echo a; false; echo b}
a
b

@jpco
Copy link
Collaborator Author

jpco commented Apr 6, 2025

Okay, actually, I think this requires a little more thought. Note that I still like most of the proposed setup, but it doesn't resolve every edge case quite as cleanly as I'd prefer. For example, take the following little script:

#!/usr/local/bin/es -e

echo before loop

let (x = true)
while {$x} {
    echo returning false
    result 3
    x = false
    echo did an assignment
}

echo after loop

Of course, the right output for this script without -e is

before loop
returning false
did an assignment
after loop

I assert that the right output with -e should be:

before loop
returning false

-- that is, the result 3 escapes the while loop and the %batch-loop.

Now here's where it gets funky. This fork's es, run with -e, outputs

before loop
returning false
returning false
returning false
...

Infinite loop is not very good.

What's even weirder is that es at HEAD with -e outputs:

before loop
returning false
did an assignment
after loop

The -e has no effect at all!

Both of these wrong behaviors come down to how fn-while is defined.

fn-while = $&noreturn @ cond body {
    catch @ e value {
        if {!~ $e break} {
            throw $e $value
        }
        result $value
    } {
        let (result = <=true)
            forever {
                if {!$cond} {
                    throw break $result
                } {
                    result = <={$body}
                }
            }
    }
}

While running, while has to save the return value of $body so that, if the next $cond evaluates falsey, while can return with the return value of the last $body that was run. It's this saving -- the result = <={$body} -- that causes all the mayhem. In the current shell, <= turns off -e entirely while $body is executing. If $body returns false overall, then the assignment to result will cause an exit (which is also a behavior I don't like), but a simple false result inside of $body has no effect. In the current state of the fork here, the <= itself catches the false exception generated within $body, and the infinite loop happens because the command setting x = false is never reached, so the loop just continues forever.

My initial thought was, okay, let's make <= not catch false. This makes the above script work as expected. But it also screws up, for example, redirections: {%open 0 <={%one file} cmd} requires that <={%one file} can run and return file successfully (similarly, things like ~ $#* 1 need this). So let's leave <= as a false-catcher, and instead we can change the result = <={$body} to a result <={result = <={$body}}, which looks goofy but gives us the semantics we want. The only other place we have to change in initial.es is %interactive-loop itself, which does result = <={$fn-%dispatch $code}, but because %dispatch is where the exit-on-false behavior is enabled, just doing result <={result = <={$fn-%dispatch $code}} doesn't work; you need a custom exception-handler wrapping the $fn-%dispatch call that converts the false to something else that <= won't consume.


So this is all kind of funky. And I think the question of "so what's the point of keeping false and error distinct?" is a good one. Why should cat < badfile and cat badfile need separate handling? Wouldn't it be nice if es -e just caused external errors to throw an error just like internal ones do so things could be unified?

Yes, that would be nice. And that's basically what elvish does; they throw out "exit statuses" in its Unix-y concept entirely and make it so that external binaries trigger an exception if they don't exit 0, and anything built-in also just throws an exception on failure. But es isn't quite so aggressive in forsaking the Unix model, and unless we decide to be, we need to support "exceptions on false" (es with -e), "exit codes on false" (es without -e) and "normal return values" all at once, whereas most languages only pick two of those to deal with. I doubt there's any way to reasonably handle all of them with a single exception handled a single way. So to try to bridge the gap, we kludge in the false exception, which is essentially "it's like the error exception, except it can be handled with if and %or and %not like an exit status". So, the same scripts can run with or without -e mostly-unmodified, with just a little consideration taken in control-flow functions.

I think that's about as good as we can get.

@jpco
Copy link
Collaborator Author

jpco commented Apr 7, 2025

Actually, there may be a different way to do it, where only %echo-status from #149 and a couple other commands. I'll poke at that for a bit.

jpco added a commit to jpco/es-shell that referenced this pull request Jun 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants