You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Re-implement Ammonite's Ctrl-C interruption for Scala REPL via bytecode instrumentation (#24194)
Since `Thread.stop` was removed
(https://stackoverflow.com/questions/4426592/why-thread-stop-doesnt-work),
the only way to re-implement such functionality is via bytecode
instrumentation. This PR implements such functionality in the Scala
REPL, such that we can now `Ctrl-C` to interrupt runaway code that
doesn't check for `Thread.isInterrupted()` (which is what the current
`Thread.interrupt` does) without needing to kill the entire JVM
These snippets are now interruptable where they weren't before:
```scala
scala> scala.collection.Iterator.continually(1).foreach(x => scala.Predef.identity(x))
^C
Interrupting running thread
java.lang.ThreadDeath
at dotty.tools.repl.ReplCancel.stopCheck(ReplCancel.scala:22)
at scala.collection.IterableOnceOps.foreach(IterableOnce.scala:619)
at scala.collection.IterableOnceOps.foreach$(IterableOnce.scala:617)
at scala.collection.AbstractIterator.foreach(Iterator.scala:1306)
... 32 elided
scala>
scala> var x = 1; while(true) x += 1
^C
Interrupting running thread
java.lang.ThreadDeath
at dotty.tools.repl.ReplCancel.stopCheck(ReplCancel.scala:22)
... 32 elided
scala>
scala> def fib(n: Int): Int = if (n <= 0) 1 else fib(n-1) + fib(n-2); fib(99)
^C
Interrupting running thread
java.lang.ThreadDeath
at dotty.tools.repl.ReplCancel.throwIfReplStopped(ReplCancel.scala:16)
at rs$line$2$.fib(rs$line$2)
at rs$line$2$.fib(rs$line$2:1)
at rs$line$2$.fib(rs$line$2:1)
...
scala>
```
The way this works is that we instrument all bytecode that gets loaded
into the REPL classloader using `scala.tools.asm` and add checks at
every backwards branch and start of each method body. These checks call
a classloader-scoped `ReplCancel.stopCheck` method, and the `Ctrl-C`
handler is wired up to flip a var and make the `stopCheck()` calls fail.
## Configuration
This feature is controlled by the `-Xrepl-interrupt-instrumentation`
that can take three settings
- `true` (default): all non-JDK classfiles loaded into the REPL are
instrumented. This allows interruption of both local code and
third-party libraries, but results in the REPL classes being
incompatible with parent classloader classes and cannot be shared across
the classloader boundary since the REPL uses its own instrumented copies
of those classes.
- `local`: only REPL classes are instrumented. Only local code can be
interrupted, but long-running library code such as `Iterator.range(0,
Int.MaxValue).max` cannot. But REPL-defined classes can be shared with
the parent classloaded
- `false`: all instrumentation is disabled, interruption is not
supported in REPL-define classes or library classes
|| true (default) | local | false |
|-----|-------|------|------|
| REPL code interruptable (e.g. `while(true) ()`) | Y | Y | N |
| library code interruptable (e.g. `Iterator.range(0,
Int.MaxValue).max`) | Y | N | N |
| REPL classes can interop with parent classloaded | N | Y | Y |
| Performance Overhead | More | Less | None |
The `true` default above also appears to be what JShell does, which also
supports interruption of library classes, e.g. code such as
`scala.collection.Iterator.range(0,
Integer.MAX_VALUE).max(scala.math.Ordering.Int$.MODULE$);` can be
interrupted in JShell even though the hot loop is in the library and not
in REPL code
## Performance Impact
This adds some incremental performance hit to code running in the Scala
REPL, but the result of no longer needing to trash your entire REPL
process and session history due to a single runaway command is probably
worth it. There may be other ways to instrument the code to minimize the
performance hit. Some rough benchmarks:
```scala
var start = System.nanoTime(); var x = 1L; while(true) { x += 1; if (x % 100000000 == 0){ val next = System.nanoTime(); println(next - start); start = next}}
```
- `if (boolean) throw` (the current implementation): ~2ns per loop
- `1/int`, which throws when `int == 0`: ~2ns per loop
- `if (Thread.interrupted())`: ~2ns per loop
- No instrumentation: ~1ns per loop
An exponential-but-technically-not-infinite recursion benchmark below
shows a minor slowdown from the start-of-method-body instrumentation
(~6%):
```scala
def fib(n: Int): Int = if (n <= 0) 1 else fib(n-1) + fib(n-2); val now = System.nanoTime(); fib(40); val duration = System.nanoTime() - now
```
- With instrumentation: 753,994,875ns
- No instrumentation: 712,178,417ns
This 50% slowdown is the worst case slowdown that instrumentation adds;
anything more complex than a `while(true) x += 1` loop will have a
longer time taken, and the % slowdown from instrumentation would be
smaller. Probably can expect a 10-20% slowdown on more typical code
This instrumentation is on by default on the assumption that most REPL
work isn't performance sensitive, but I added a flag to switch it off
and fall back to the prior un-instrumented behavior which would require
terminating the process to stop runaway code.
One consequence of this is that REPL-loaded classes will be different
from non-REPL-loaded classes, due to the bytecode instrumentation and
class re-definition. So use cases embedding the REPL into an existing
program to interact with it "live" would need to pass
`-Xrepl-bytecode-instrumentation:false` or
`-Xrepl-bytecode-instrumentation:local` to allow classes and instances
to be shared between them
The `jshell` REPL also allows interruption of these snippets, and likely
uses a similar approach though I haven't checked
---------
Co-authored-by: Oliver Bračevac <[email protected]>
"pass `false` to disable bytecode instrumentation for interrupt handling in REPL, or `local` to limit interrupt support to only REPL-defined classes",
333
+
"true"
334
+
)
328
335
valXverifySignatures:Setting[Boolean] =BooleanSetting(AdvancedSetting, "Xverify-signatures", "Verify generic signatures in generated bytecode.")
329
336
valXignoreScala2Macros:Setting[Boolean] =BooleanSetting(AdvancedSetting, "Xignore-scala2-macros", "Ignore errors when compiling code that calls Scala2 macros, these will fail at runtime.")
330
337
valXimportSuggestionTimeout:Setting[Int] =IntSetting(AdvancedSetting, "Ximport-suggestion-timeout", "Timeout (in ms) for searching for import suggestions when errors are reported.", 8000)
0 commit comments