RunawayBoxInterruptSmokeTest.kt
src/jvmMain/kotlin/afield/runtime/lifecycle/RunawayBoxInterruptSmokeTest.kt · 181 lines
package afield.runtime.lifecycle
import afield.app.protocol.Execute
import afield.app.protocol.Message
import afield.app.protocol.Rect
import afield.app.server.SessionInformation
import afield.app.server.createAgentBox
import afield.app.server.executeAllRequest
import afield.app.server.thisSessionInfo
import afield.document.graph.Box
import afield.document.persistence.SavingContext
import java.io.File
import java.nio.file.Files
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlin.system.exitProcess
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
FIELD_TEST id: runtime.runaway-box-interrupt.smoke runner: jvm-main entrypoint: afield.runtime.lifecycle.RunawayBoxInterruptSmokeTest tags: [smoke, kotlin, k2, runloop, regression] timeoutSec: 600 expect: exitCode: 0 stdoutContains: - "RunawayBoxInterruptSmokeTest ok"
object RunawayBoxInterruptSmokeTest
Box code that never returns is thrown out of, and Field carries on (BoxInterrupt).
Drives execute-all's own body with a live Runloop, with the watchdog set to interrupt after
one second of no frames:
- A.
while (true) {}is interrupted by the watchdog within 2 s, its verdict is a failure that says so, and the frame counter moves again. - B. A runaway recursion (exponential
fib, which never overflows the stack) likewise. - C. A
while (true)that catchesException(andThread.sleeps) is still interrupted. - D. The next, ordinary box runs and prints.
- E. With the watchdog's auto-interrupt off,
BoxInterrupt.requestfrom another thread (what the "Interrupt Running Box" command does) ends a runaway loop.
object RunawayBoxInterruptSmokeTest {
private val failures = mutableListOf<String>()
private fun check(label: String, ok: Boolean, detail: String) {
println(" ${if (ok) "ok " else "FAIL"} $label: $detail")
if (!ok) failures += "$label: $detail"
}
private var requests = 0
private class Outcome(val ok: Boolean, val error: String, val stdout: String, val ms: Long)
private fun run(info: SessionInformation, box: Box, code: String): Outcome {
val verdict = CompletableFuture<JsonObject>()
val out = StringBuilder()
val exec = Execute(uid = box.id, type = "code", line = 0, code = code, singleLine = false, requestId = "runaway-${requests++}")
val t0 = System.nanoTime()
var t1 = 0L
executeAllRequest(box, exec, { message: Message ->
if (message.name == "execution-verdict") { t1 = System.nanoTime(); verdict.complete(message.payload as JsonObject) }
else synchronized(out) { out.append(message.payload.toString()).append('\n') }
}, null, info)
val payload = verdict.get(180, TimeUnit.SECONDS)
return Outcome(payload["success"]?.jsonPrimitive?.content == "true",
payload["error"]?.jsonPrimitive?.content.orEmpty(), synchronized(out) { out.toString() }, (t1 - t0) / 1_000_000)
}
The Runloop makes frames again: the counter advances by at least 3 within 5 s.
private fun framesMove(): Boolean {
val f0 = Runloop.runLoop.frame
val deadline = System.currentTimeMillis() + 5000
while (System.currentTimeMillis() < deadline) {
if (Runloop.runLoop.frame >= f0 + 3) return true
Thread.sleep(10)
}
return false
}
fun main(args: Array<String>)
body — 95 lines, from line 86
@JvmStatic
fun main(args: Array<String>) {
BoxInterrupt.Watchdog.reportAfterMs = 500
BoxInterrupt.Watchdog.interruptAfterMs = 1000
startRunloopDaemon()
val info = freshSession()
var stalls = 0
BoxInterrupt.Watchdog.onStall += { who, ms, _ -> stalls++; println(" stall reported: $who after $ms ms") }
try {
// Warm the engine so compile time is not counted against the 2 s.
val warm = createAgentBox(info, "warm", Rect(0.0, 0.0, 100.0, 100.0), emptyMap())
run(info, warm, "println(\"warm\")")
val spin = createAgentBox(info, "spin", Rect(0.0, 0.0, 100.0, 100.0), emptyMap())
val a = run(info, spin, "var i = 0L\nwhile (true) { i++ }")
check("A while(true) fails", !a.ok, a.error.lineSequence().firstOrNull().orEmpty())
check("A error says interrupted", "interrupted" in a.error, a.error.take(200))
check("A within 2 s of the watchdog threshold", a.ms < 1000 + 2000, "${a.ms} ms (threshold 1000 ms)")
check("A frames move again", framesMove(), "frame=${Runloop.runLoop.frame}")
check("A stall was reported naming the box", stalls > 0, "stalls=$stalls")
val rec = createAgentBox(info, "recursion", Rect(0.0, 0.0, 100.0, 100.0), emptyMap())
val b = run(info, rec, "fun fib(n: Int): Long = if (n < 2) n.toLong() else fib(n - 1) + fib(n - 2)\nprintln(fib(90))")
check("B runaway recursion fails", !b.ok && "interrupted" in b.error, b.error.take(200))
check("B within 2 s of the threshold", b.ms < 1000 + 2000, "${b.ms} ms")
check("B frames move again", framesMove(), "frame=${Runloop.runLoop.frame}")
val swallow = createAgentBox(info, "swallow", Rect(0.0, 0.0, 100.0, 100.0), emptyMap())
val c = run(info, swallow, "while (true) { try { Thread.sleep(50) } catch (e: Exception) { } }")
check("C catch(Exception) does not swallow it", !c.ok && "interrupted" in c.error, c.error.take(200))
check("C frames move again", framesMove(), "frame=${Runloop.runLoop.frame}")
val normal = createAgentBox(info, "normal", Rect(0.0, 0.0, 100.0, 100.0), emptyMap())
val d = run(info, normal, "println(\"after the runaway: \" + (6 * 7))")
check("D next box succeeds", d.ok, d.error.ifBlank { "succeeded" })
check("D and prints", "after the runaway: 42" in d.stdout, d.stdout.takeLast(300))
BoxInterrupt.Watchdog.interruptAfterMs = 0
val manual = createAgentBox(info, "manual", Rect(0.0, 0.0, 100.0, 100.0), emptyMap())
val asker = Thread {
Thread.sleep(1500)
BoxInterrupt.request("smoke test command")
}.apply { isDaemon = true; start() }
val e = run(info, manual, "var x = 0\nwhile (true) { x = x xor 1 }")
asker.join()
check("E request() interrupts", !e.ok && "smoke test command" in e.error, e.error.take(200))
check("E frames move again", framesMove(), "frame=${Runloop.runLoop.frame}")
check("E no request left pending", !BoxInterrupt.isPending(), "pending=${BoxInterrupt.isPending()}")
} catch (t: Throwable) {
failures += "harness threw ${t.javaClass.simpleName}: ${t.message}"
t.printStackTrace(System.err)
} finally {
thisSessionInfo.remove()
}
if (failures.isNotEmpty()) {
System.err.println("RunawayBoxInterruptSmokeTest FAILED with ${failures.size} problem(s):")
failures.forEach { System.err.println(" - $it") }
exitProcess(1)
}
println("RunawayBoxInterruptSmokeTest ok")
exitProcess(0)
}
private fun freshSession(): SessionInformation {
thisSessionInfo.remove()
val workspace = Files.createTempDirectory("afield-runaway-box").toFile()
workspace.deleteOnExit()
val document = File(workspace, "doc").also { it.mkdirs() }
File(document, SavingContext.METADATA_EDN).writeText("{:version 2 :boxes []}\n")
val root = Box("root")
val session = SessionInformation(
filename = document.absolutePath,
documentName = document.name,
boxes = ConcurrentHashMap(),
root = root,
ephemeral = true,
)
root.inside = session
session.boxes[root.id] = root
thisSessionInfo.set(session)
return session
}
private fun startRunloopDaemon() {
val t = Thread({ Runloop.runLoop.start() }, "runaway-box-harness-runloop")
t.isDaemon = true
t.start()
val deadline = System.currentTimeMillis() + 30_000
while (Runloop.runLoop.frame < 1L) {
check(System.currentTimeMillis() < deadline) { "runloop did not start within 30s" }
Thread.sleep(20)
}
}
}