Skip to content

BoxInterrupt.kt

src/jvmMain/kotlin/afield/runtime/lifecycle/BoxInterrupt.kt · 304 lines

package afield.runtime.lifecycle

import afield.app.diagnostics.logging.lprintln
import org.jetbrains.org.objectweb.asm.ClassReader
import org.jetbrains.org.objectweb.asm.ClassVisitor
import org.jetbrains.org.objectweb.asm.ClassWriter
import org.jetbrains.org.objectweb.asm.Label
import org.jetbrains.org.objectweb.asm.MethodVisitor
import org.jetbrains.org.objectweb.asm.Opcodes
import java.util.Collections
import java.util.WeakHashMap
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicReference
import kotlin.script.experimental.api.CompiledScript
import kotlin.script.experimental.jvm.impl.KJvmCompiledModuleInMemory
import kotlin.script.experimental.jvm.impl.KJvmCompiledScript

Getting out of box code that will not return: a stall watchdog, and the bytecode rewriting that gives the interrupt somewhere to land.

The whole public surface is BoxInterrupt: request an interrupt, poll for one, instrument a freshly compiled snippet, and the nested watchdog that notices a Runloop which has stopped making frames. The rest of the file is the ASM class and method visitors that insert the poll call, and nothing outside the file uses them. dev_docs/RUNAWAY_BOX_INTERRUPT.md has the design.

object BoxInterrupt

A stall watchdog and a cooperative escape, for box code that never returns.

Box code runs on the Runloop thread, so a while (true) {} in a box stops every frame, every other box, and every coroutine that would have run stop. Java no longer offers a way to stop a thread from outside (Thread.stop throws since JDK 20), so the escape is cooperative, and the cooperation is compiled in: before a Kotlin snippet is evaluated, instrument rewrites its classes so that every method entry and every backward jump (the back-edge of every loop) calls poll. poll is one volatile read until an interrupt has been requested for the thread it runs on; then it throws BoxInterruptedError out of the box code, and the box's run ends with an error the usual way.

An interrupt is requested by the "Interrupt Running Box" command (interrupt-box, dispatched on the websocket's coroutine rather than the blocked Runloop), by the agent harness's RunBox timeout, or by the Watchdog after the Runloop has made no frame for Watchdog.interruptAfterMs. The watchdog also reports the stall, naming the box from the Runloop thread's stack (snippet class names are recorded against their box when instrumented, because RunScope.current is thread-local and cannot be read from another thread).

What it cannot reach: a loop inside library or JDK code that box code called (a blocking Thread.sleep, a CountDownLatch.await, a long native call) never polls. The watchdog still reports those, and the interrupt request also calls Thread.interrupt(), which wakes the JDK's blocking waits when box code is parked in one. See dev_docs/RUNAWAY_BOX_INTERRUPT.md.

object BoxInterrupt {

class BoxInterruptedError(val reason: String) : Error("Box interrupted: $reason")

Thrown out of box code by poll. An Error, so catch (e: Exception) in a box does not swallow it.

    class BoxInterruptedError(val reason: String) : Error("Box interrupted: $reason") {

The stack is the box's own frames at the poll, which is the useful part.

    private class Request(val thread: Thread, val reason: String, val frameAtRequest: Long)

    private val pending = AtomicReference<Request?>(null)

Box labels by snippet class internal name (Line_12_jsr223/...), for naming a stall's culprit.

    private val classOwners = ConcurrentHashMap<String, String>()

    private val instrumented: MutableSet<Any> = Collections.synchronizedSet(Collections.newSetFromMap(WeakHashMap()))

val onInterrupted = CopyOnWriteArrayList<(BoxInterruptedError) -> Unit>()

Listeners told about every interrupt that actually fired (the UI and tests).

AFIELD_BOX_INTERRUPT / -Dafield.boxInterrupt, when set; it overrides the welcome-page preference.

    private val override: Boolean? = setting("AFIELD_BOX_INTERRUPT", "afield.boxInterrupt")?.let { it != "0" && it != "off" && it != "false" }

var enabled: Boolean

Whether newly compiled box code polls, and whether the Watchdog may interrupt. Off, the watchdog still reports stalls. Code compiled while it was on stays interruptible until it is recompiled.

    @Volatile
    var enabled: Boolean = override ?: true

fun applyPreference(on: Boolean)

Apply the persisted preference (FieldPreferences.boxInterrupt), unless the env/property override is set.

    fun applyPreference(on: Boolean) {
        enabled = override ?: on
    }

fun poll()

Called by instrumented box code at every method entry and loop back-edge. Must stay cheap: the common path is one opaque read and a null check. Opaque, not volatile: a volatile read on every back-edge is an acquire barrier that stops the JIT vectorising the loop (a[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) += i over an IntArray ran ~20x slower), while an opaque read still cannot be hoisted out of the loop, so a request is seen within an iteration or two. The compare-and-set below is the real fence.

    @JvmStatic
    fun poll() {
        val r = pending.getOpaque() ?: return
        if (r.thread !== Thread.currentThread()) return
        // A request outlives the box it was aimed at when that box returned on its own (or none was
        // running). The box that is stuck is stuck inside the frame it was asked in, so a request
        // from an earlier frame is stale: drop it rather than fire in whatever box runs next.
        if (Runloop.runLoop.frame != r.frameAtRequest) { pending.compareAndSet(r, null); return }
        if (!pending.compareAndSet(r, null)) return
        Thread.interrupted() // clear the flag [request] set, so it does not leak into later code
        val e = BoxInterruptedError(r.reason)
        lprintln("warn,execution,interrupt", "interrupted box code: ${r.reason}")
        onInterrupted.forEach { runCatching { it(e) } }
        throw e
    }

fun request(reason: String, thread: Thread? = Runloop.mainThread): Boolean

Ask thread (by default the Runloop thread) to leave box code at its next poll. Returns false when there is no such thread. Safe to call from any thread, including while the Runloop is blocked. A request is withdrawn by the Watchdog once the Runloop makes progress again, so a request that found no box code to land in cannot fire later in an unrelated box.

    fun request(reason: String, thread: Thread? = Runloop.mainThread): Boolean {
        val t = thread ?: return false
        pending.set(Request(t, reason, Runloop.runLoop.frame))
        // Wake a sleep/wait/park that box code is blocked in, so its next back-edge polls. Only then:
        // an interrupt flag left on the Runloop thread outside box code would break its idle park.
        val stack = t.stackTrace
        val boxDepth = stack.indexOfFirst { classOwners.containsKey(it.className.replace('.', '/')) }
        if (boxDepth >= 0 && stack.take(boxDepth).any { it.isBlockingWait() }) t.interrupt()
        return true
    }

    private fun StackTraceElement.isBlockingWait(): Boolean =
        (className == "java.lang.Thread" && methodName.startsWith("sleep")) ||
            (className == "java.lang.Object" && methodName == "wait0") ||
            (className == "jdk.internal.misc.Unsafe" && methodName == "park")

fun isPending(): Boolean = pending.get() != null

True while a request has not yet landed or been withdrawn.

internal fun withdrawIfFrameAdvanced(frame: Long)

    internal fun withdrawIfFrameAdvanced(frame: Long) {
        val r = pending.get() ?: return
        if (frame != r.frameAtRequest) pending.compareAndSet(r, null)
    }

fun culprit(stack: Array<StackTraceElement>): String?

Box label that owns the innermost snippet frame in stack, or null.

    fun culprit(stack: Array<StackTraceElement>): String? {
        for (e in stack) {
            val owner = classOwners[e.className.replace('.', '/')]
            if (owner != null) return owner
        }
        return null
    }

fun instrument(script: CompiledScript, boxLabel: String)

Rewrite the newest snippet of script so its code polls. Idempotent per compiled module; a module of an unknown shape is left alone (and logged once).

    fun instrument(script: CompiledScript, boxLabel: String) {
        if (!enabled) return
        val k = script as? KJvmCompiledScript ?: return
        val module = k.getCompiledModule() as? KJvmCompiledModuleInMemory ?: return
        if (!instrumented.add(module)) return
        @Suppress("UNCHECKED_CAST")
        val files = module.compilerOutputFiles as? MutableMap<String, ByteArray> ?: return
        try {
            for ((name, bytes) in files.entries.toList()) {
                if (!name.endsWith(".class")) continue
                files[name] = instrumentClass(bytes, boxLabel) ?: continue
            }
        } catch (t: UnsupportedOperationException) {
            if (warnedImmutable.compareAndSet(false, true))
                lprintln("warn,execution,interrupt", "snippet module is read-only; box code will not be interruptible")
        }
    }

    private val warnedImmutable = java.util.concurrent.atomic.AtomicBoolean(false)

fun instrumentClass(bytes: ByteArray, boxLabel: String): ByteArray?

bytes rewritten to poll, recording its class as boxLabel's; null if it is not rewritable (then the class runs unchanged, just not interruptibly).

body — 59 lines, from line 178
    fun instrumentClass(bytes: ByteArray, boxLabel: String): ByteArray? {
        return try {
            val reader = ClassReader(bytes)
            // COMPUTE_MAXS only: the inserted call has no stack effect and is never placed at a
            // label, so existing stack-map frames stay valid and no class loading is needed.
            val writer = ClassWriter(reader, ClassWriter.COMPUTE_MAXS)
            reader.accept(PollingClassVisitor(writer), 0)
            writer.toByteArray().also { classOwners[reader.className] = boxLabel }
        } catch (t: Throwable) {
            lprintln("warn,execution,interrupt", "could not instrument a snippet class: $t")
            null
        }
    }

    private const val OWNER = "afield/runtime/lifecycle/BoxInterrupt"

    private class PollingClassVisitor(next: ClassVisitor) : ClassVisitor(Opcodes.API_VERSION, next) {
        override fun visitMethod(access: Int, name: String?, descriptor: String?, signature: String?, exceptions: Array<out String>?): MethodVisitor? {
            val mv = super.visitMethod(access, name, descriptor, signature, exceptions) ?: return null
            if (access and (Opcodes.ACC_ABSTRACT or Opcodes.ACC_NATIVE) != 0) return mv
            // Constructors poll at back-edges only: a call before super() is legal bytecode, but
            // keeping <init> entry untouched keeps us clear of any verifier subtlety there.
            return PollingMethodVisitor(mv, pollAtEntry = name != "<init>" && name != "<clinit>")
        }
    }

    private class PollingMethodVisitor(next: MethodVisitor, val pollAtEntry: Boolean) : MethodVisitor(Opcodes.API_VERSION, next) {
        private val seen = HashSet<Label>()

        private fun emitPoll() = super.visitMethodInsn(Opcodes.INVOKESTATIC, OWNER, "poll", "()V", false)

        override fun visitCode() {
            super.visitCode()
            if (pollAtEntry) emitPoll()
        }

        override fun visitLabel(label: Label) {
            seen.add(label)
            super.visitLabel(label)
        }

        override fun visitJumpInsn(opcode: Int, label: Label) {
            // A jump to a label already visited is a backward jump: the back-edge of a loop.
            // The poll goes before the jump; for a conditional jump that is before the operands
            // are consumed, which is fine because poll() has no stack effect.
            if (label in seen) emitPoll()
            super.visitJumpInsn(opcode, label)
        }

        override fun visitTableSwitchInsn(min: Int, max: Int, dflt: Label, vararg labels: Label) {
            if (dflt in seen || labels.any { it in seen }) emitPoll()
            super.visitTableSwitchInsn(min, max, dflt, *labels)
        }

        override fun visitLookupSwitchInsn(dflt: Label, keys: IntArray?, labels: Array<out Label>) {
            if (dflt in seen || labels.any { it in seen }) emitPoll()
            super.visitLookupSwitchInsn(dflt, keys, labels)
        }
    }

internal fun setting(env: String, prop: String): String?

    internal fun setting(env: String, prop: String): String? =
        System.getProperty(prop)?.trim()?.lowercase() ?: System.getenv(env)?.trim()?.lowercase()

object Watchdog

Notices that Runloop.frame has stopped advancing. After reportAfterMs it logs the stall once, with the culprit box and the Runloop thread's stack; after interruptAfterMs (0 = never) it requests an interrupt. Runs on its own daemon thread; never touches Runloop state beyond reading the frame counter and the thread's stack.

@Volatile var reportAfterMs: Long

        @Volatile var reportAfterMs: Long = setting("AFIELD_RUNLOOP_WATCHDOG_REPORT_MS", "afield.runloop.watchdog.reportMs")?.toLongOrNull() ?: 1500L

@Volatile var interruptAfterMs: Long

        @Volatile var interruptAfterMs: Long = setting("AFIELD_RUNLOOP_WATCHDOG_INTERRUPT_MS", "afield.runloop.watchdog.interruptMs")?.toLongOrNull() ?: 30_000L

@Volatile var pollMs: Long = 100L

val onStall = CopyOnWriteArrayList<(String?, Long, Array<StackTraceElement>) -> Unit>()

Told about each stall report: (culprit box label or null, milliseconds stalled, stack).

        @Volatile private var thread: Thread? = null

fun start()

body — 47 lines, from line 257
        @Synchronized
        fun start() {
            if (thread != null) return
            if (setting("AFIELD_RUNLOOP_WATCHDOG", "afield.runloop.watchdog") in setOf("0", "off", "false")) return
            thread = Thread(::loop, "afield-runloop-watchdog").apply { isDaemon = true; start() }
        }

        private fun loop() {
            var lastFrame = Runloop.runLoop.frame
            var since = System.nanoTime()
            var reported = false
            var interrupted = false
            while (true) {
                try { Thread.sleep(pollMs) } catch (_: InterruptedException) { return }
                val f = Runloop.runLoop.frame
                val now = System.nanoTime()
                if (f != lastFrame) {
                    if (reported) lprintln("info,runloop,watchdog", "Runloop moving again after ${(now - since) / 1_000_000} ms")
                    lastFrame = f; since = now; reported = false; interrupted = false
                    withdrawIfFrameAdvanced(f)
                    continue
                }
                val main = Runloop.mainThread ?: continue
                // An idle Runloop parks between frames but still advances; a parked/waiting main
                // thread inside our own idle strategy is not a stall worth interrupting box code for.
                val stalledMs = (now - since) / 1_000_000
                if (!reported && reportAfterMs > 0 && stalledMs >= reportAfterMs) {
                    reported = true
                    val stack = main.stackTrace
                    val who = culprit(stack)
                    val top = stack.take(12).joinToString("\n    at ")
                    lprintln("warn,runloop,watchdog", "Runloop has not made a frame for $stalledMs ms" +
                            (who?.let { "; box code from $it is running" } ?: "") + "\n    at " + top)
                    onStall.forEach { runCatching { it(who, stalledMs, stack) } }
                }
                if (enabled && !interrupted && interruptAfterMs > 0 && stalledMs >= interruptAfterMs) {
                    val who = culprit(main.stackTrace)
                    // Only box code polls, so only ask when box code is on the stack.
                    if (who != null) {
                        interrupted = true
                        request("Runloop stalled ${stalledMs} ms in $who (watchdog)", main)
                    }
                }
            }
        }
    }
}