-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
boundary/break leaks implementation details and may cause unexpected behaviour #17737
Comments
I argued for a long time that
It won't achieve that. With one more indirection, the problem becomes invisible to what you are proposing: boundary:
loopForeverEvenOnExceptions:
makeRequest()
break()
println("Finish")
def loopForeverEvenOnExceptions(body: => Unit): Unit =
while true do
try
body
catch
case _: Exception => // loop forever anyway |
Yeah, you are right... Another hacky solution: exclude |
We cannot do that. That would undeniably make it a language feature, while it is currently nothing but two library functions. Also, that still breaks if the |
No, I think we have to change the mindset of people instead. Breaks are aborts and there should be only one kind of abort. And everytime I see a "just ignore any exception thrown and retry", alarm bells start ringing in my head. That's just a recipe for infinite loops. and we should call it out. Here's another, better example: //> using scala 3.3
import java.net.http.HttpTimeoutException
import scala.util.boundary
import boundary.break
object Main extends App:
boundary:
openResource()
while true do
try
makeRequest()
break()
catch
case ex: Exception =>
log(ex)
closeResource()
throw ex Since breaks are aborts, the resource will be closed when the break happens, which is what we want. |
Catching all exceptions, and ignoring them, is definitely an antipattern. However, many Scala programmers often write a short script and want to do it as quickly and simply as possible. I've seen people report this problem already. One of them is my friend, who is experienced in Scala, and generally a programmer who is very aware of antipatterns and writes solid code. In this case, however, he wrote a concise script waiting for HTTP service and couldn't figure out what was happening because he didn't expect the control to rely on throwing Exception . And that's what our users will do, so even if we want to discourage this mindset and enforce a different one, we should do it as painlessly as possible - via errors or warnings. Otherwise, it could be a frustrating DX problem. Maybe we should start with some errors discouraging this approach. I see two possible directions right now:
It only solves some things, but it could be a start. I think covering the most common cases can already help massively. |
I think we should try make it a linter warning if
|
I was playing with boundary/break to summarize all cases when it could happen. This one I found particularly interesting, as it doesn't involve any obvious antipatterns (because of NonFatal usage within Try). It's a real-life example of what @sjrd showed. import scala.util.boundary
import boundary.break
import scala.util.Try
@main
def main() =
boundary:
while true do
Try:
println("Foo")
break()
println("Finish") Could we add |
I don't understand the use of the word "abort" or why it isn't a I'm not sure about the "resource" example, because I would expect Perhaps the sophisticated linting under discussion will make it all clear to me. I noticed Scala 3 does not warn for catch-all:
IIRC Seth insisted on a warning when I backported this syntax. (I like the simplicity of |
@som-snytt its already released, the problem is how to fix this issue while keeping our compatibility guarantees. So no changes in types. |
Context: |
We went over this at length before. Yes, we certainly do want It's really an education problem. Unfortunately the Scala community was blinded for too long by the weak and leaking abstraction of |
If we want to move with boundary/break and NonFatal with a shared vision, and take care of their usability, we need to paint the whole picture and answer some questions. So, to summarize it: Problem addressed by current boundary/break
Additionally, there were arguments made against the sensibility of
There are more benefits, but they are out of the scope of this discussion. Solution provided by boundary/break
Rationale for the current shape of boundary/breakPrimarily, it solves the problem it was meant to solve. Additionally, a conceptual framework exists where Exceptions and breaks are both Break was not made a Problems resulting from current boundary/breakOur users find it counterintuitive that Speaking about the usability of these features requires us to consider how our users will think about it. Changing the way of thinking may provide additional value. Still, at its core, it is a cost. It negatively affects usability - causing initial confusion and possible errors that may be even worse than the ones caused by the previous way of thinking. And that happens with boundary/break - we have a breaking mechanism conceptually incompatible with solutions used in other popular languages, and it causes confusion and frustration. Worse, it's incompatible in a hidden way that will appear only in runtime. And it won't cause any errors. It will just work differently. Our user can even not notice that until, e.g. in production. Proposed solutionThe first proposal was to introduce warnings. That may help as a complementary or temporary solution, but more is needed to solve the core problem. Warnings are easy to miss and won't help with the confusion and frustration - users will still perceive this behavior as odd. After some internal discussions, we arrived at the idea of the following components. Introduce
|
I think it's essential that both Otherwise the following code would lead to a race condition when determining the result of boundary:
val f = Future { break(1) }
f.onComplete: r =>
println(r.get)
2 |
That said, if |
But if future lets the break escape, then it will be non-deterministically caught by the boundary? (or do you mean the |
If If it does catch the |
Yes, if two futures both break they race for the value returned from boundary. The principal idea of a future is that all possible outcomes are accessed when the future's result is demanded. We want to keep that model. |
And this would be fundamentally broken, in my opinion. Why do that but treat exceptions differently? |
I don't think anyone suggests that However, it seems most (all) reasonable use cases want |
But a Future returns a Try. So sometimes the Try catches a Break and sometimes not? 😕 |
Sometimes a
|
In what sense? I thought |
Yes, and everyone else in this thread says it's the wrong thing. 🤷♂️ That's why this thread is still going on. |
I agree that
An operating system-like program such as Akka might want a finer distinction. That's OK, but don't publish that as the best practice to users. |
I think that the point people want to make is that
Note that exactly this issue came up when we designed |
Emphatically disagree. If you want to catch & handle all aborts, |
fyi @patriknw |
Given that, after everything that's been said in this thread, you are still convinced that |
In my summary, I was not proposing that Break should be an Error. There are arguments for and against that, and there is not much we can do now anyway. So I will not argue for that. What I was proposing was making the behavior more intuitive. One way to do that is to acknowledge that Future and Try have different capacities regarding interactions with breaking mechanisms and to make it expressable - given that it is, per our users' reports, the behavior they expect.
It does, and part of the solution of the usability problem is changing this behavior - making the Try support breaking and avoiding the confusion that would arise from a lack of its support. One of the arguments for that, a simple one, is that it would be a bad practice to use
Try would always behave the same (in this case - not catch the Break). The Future would catch and express the Break with the Try data type. Even now, it's possible to create a Try with NonFatal, despite @sjrd I think that even without changing Break to Error, we can address this issue with the steps I suggested in my comment:
|
A bit late in the game, but I think one aspect that wasn't mentioned is that exceptions are often caught because they are exceptional, while breaks might be quite expected and part of a "normal" program flow. In the example: //> using scala 3.3
import scala.util.boundary
import boundary.break
object Main extends App:
boundary:
openResource()
while true do
try
makeRequest()
break()
catch
case ex: Exception =>
log(ex)
throw ex
finally closeResource() (apart from moving |
I am aware of that. But if we want to make progress here we have to push back against this expectation. Breaks and exceptions are both aborts, and we certainly don't want to have two kinds of handling aborts with weird feature interactions between them. What is exceptional or not is very much in the eye of the beholder. |
Ok, maybe I misunderstood the feature. I was under the impression that |
Agreed. In my world-view, exceptions are also a control mechanism, and programs need to take account of both breaks and exceptions. For instance when it comes to resource safety. I believe, traditionally, this conception that "exceptions are for exceptional cases therefore I can ignore them for now" has done a lot of harm, and has caused some parts of the community to argue against exceptions altogether. But exceptions and breaks are really the same, and once we use higher-level abstractions like |
Another point I don't see mentioned is performance. Even if you ignore the annoyance of having to explicitly handle Break if you use Try or catching Exception, it prevents, or at least makes it more difficult, to optimize break as a goto instead of a throw. And even if you consider a break to be exceptional/an abort, I think that wanting to catch any non-fatal exception that isn't Break or similar is probably common enough that is worth having a NonControl unapply and an alternative constructor for Try that doesn't catch Break. |
I wonder if it would be worth adding methods to the object Try {
def catchExceptions[T](r: => T): Try[T] = try Sucess(r) catch { case e: Exception => Failure(e) }
def catchNonError[T](r: => T): Try[T] = try Success(r) catch {
case t: Throwable if !t.isInstanceOf[java.lang.Error] => Failure(e)
}
// An optimizer could potentially use this to determine that a break can be rewritten as a jump instead
// of a throw.
def catchExceptionBesidesBreak[T](r: => T): Try[T] = try Sucess(r) catch {
case e: Exception if !e.isInstanceOf[Break] => Failure(e)
}
// this one may be ill-advised
def catchAll[T](r: => T): Try[T] = try Success(r) catch { case t: Throwable => Failure(t) } |
break is implemented as a goto when that's possible. I think we need to discuss new additions to I also think everything that needed to be said on this issue is said, so I am closing it. |
I'm aware. My point is that currently if you use import scala.util.boundary
import boundary.break
import scala.util.Try
@main
def main() =
boundary:
while true do
Try.catchExceptionsBesidesBreak:
println("Foo")
break()
println("Finish") then hopefully the break could be implemented as goto in that case as well. |
I see. Thanks for explaining. Yes, you could do that. But generating an exception is not that expensive -- about the cost of 3 virtual method calls according to John Rose. So I am not sure it matters much. |
@tmccombs - I think the solution is just to use a different library. I rewrote everything in my library to not use Try, not use NonFatal, and instead permit control flow (but not ControlThrowable since it's deprecated, basically). The nice thing about that is that when you see standard constructs you know, "Oh, this clobbers control flow", and when you see the other ones, you know, "Oh, this clobbers your threads if you mess up control flow". So you at least know what to be careful about. I don't see any particular reason the standard library needs to support both. You can't really use |
That isn't always an option. Specifically, if you need to interact with other scala libraries that do use Try, NonFatal, etc. And these types are also used in other parts of the standard library as well, including in |
Yes, so I rewrote those, too. In the case of libraries where you necessarily have lots of code in, outside of, around, etc., their uses of these things, well, yes, just use those. But they probably weren't thinking that control flow would escape, so enforcing that it can't is fine. It's still nice to have the control flow constructs available for those patches of code where you don't have to interface with some library that's deeply dependent on control-flow-eating standard library constructs. For instance, exiting a Try:
for x <- xs yield
if just(x) then // Help, how do I stop with just g(x)?
else f(x) But not having to worry about thread escape is also nice. Try:
for x <- xs yield
val y = Future:
if just(x) then // Ack, what if I attempt to jump out to the Try?!
else g(x)
h(y) So, having two sets of constructs with different safety guarantees is handy enough. In my library, the first one is easy and the second is a danger. (If you use type inference, most of the dangers are caught automatically, but still.) Err.Or:
for x <- xs yield
if just(x) then Is.break(g(x)) // Early exit with success value!
else f(x) Err.Or:
for x <- xs yield
val y = Fu:
if just(x) then Is.break(g(x)) // Argh, tried to break to top!!!
else g(x)
h(y) One could argue which should be the primary one, but having both is strictly better so it's not such a big deal which way the standard library goes as long as there's an alternate that isn't too hard to create or get. (My library is not meant to be stable and relied upon, but Ox is.) |
Compiler version
3.3.0
Minimized code
A simple example of a code that contains behavior unexpected by the user, caused by implementation detail of boundary/break:
This loop would never finish, as it would first catch the real timeout exception and then all the
Break
exceptions. It makes perfect sense it works that way, given how the boundary/break is implemented. However, catching all the Exceptions on retries when, e.g., waiting for a service to start responding, is a widespread pattern. Scala users writing simple programs like this must be aware of this implementation detail.What's more - that's a basic example. Along the way, library maintainers will use the boundary/break in their libraries. Given an additional layer over this mechanism, the leaking implementation detail will be even more misleading.
To solve the antipattern of catching
Exception
, we offer theNonFatal
with unapply. However, Break will be caught as NonFatal by the users, so it won't help as well. It may even cause more confusion, as the previous implementation detail of breaking (theControlThrowable
) was not matched by NonFatal and was sometimes used as a solution.Proposal
After talking about it, we propose that the compiler could report an error if there is a Label in the implicit scope and the Break is caught indirectly (i.e. by matching Exception). It would prevent the users from experiencing the leakage of implementation detail and the possible confusion when their code does not work as expected. Nevertheless, I would like to hear some feedback on that.
CC @odersky @bishabosha @sjrd
Update: Problem with the solution we proposed is that it would not solve the problem of runtime matching in unapplies (so, for example, NonFatal)
The text was updated successfully, but these errors were encountered: