TimeoutGuard

Inlined per-tick time guard. ScriptWeave.instrument inlines if (killId == myEvalId) throw timeout (GETSTATIC + LDC + IF_ICMPNE + GETSTATIC timeout + ATHROW) at every method entry, loop back-edge and (non-self-ref) catch handler entry of a script's own bytecode. Being inlined it pushes NO new frame, so it runs even at the stack-full edge — inside the catch handler's existing frame — where an INVOKESTATIC guard would itself StackOverflowError. One mechanism handles BOTH dead loops (back-edges) and dead recursion (the checks fire as SOE unwinds through catch handlers).

The budget bounds the INTERVAL BETWEEN HEARTBEATS, not the wall time one eval spends on the lane thread — the two come apart where vanilla re-enters the pump ([endFrame]). A lane whose heartbeat keeps firing is a game that keeps running, whatever a script is doing inside it; frozen means the loop stopped advancing — what `ServerWatchdog` kills a dedicated server over — and nothing short of that counts.

One kill-id field per `GuardLane` — a GETSTATIC-cheap field can't be thread-local, so instead of one shared field that the server and render threads would trample, there is one per lane-thread. Which one a script reads is decided at instrument time from the target lane, so compilation bakes in the right GETSTATIC with no runtime thread sniffing. Those two fields are the only per-lane STATE here; everything else lives in a `GuardState` indexed by the lane's `ordinal`, so this file has exactly one lane branch ([setKillId]).

An ID rather than a flag, because instrumented code OUTLIVES its eval: a [Patches] handler and a script-spawned thread both keep running after the eval that compiled them is gone, and a per-lane flag would kill them whenever any LATER eval on that lane went over budget. The id is baked into the bytes at instrument time, so it travels with the code and can only ever match the eval it came from. What remains is code outliving an eval that WAS killed reading its own id — its own eval's business, and bounded by [exitStep]. Past that clear the id is never raised again, so such code runs unguarded for good — see [Patches].

Types

Link copied to clipboard
private class Caught(val thread: Thread, val trace: Array<StackTraceElement>)

Where the watchdog found a lane thread standing when it spent that lane's budget. ScriptTimeoutError is an immutable process-wide singleton carrying no stack of its own, so this is the side channel a timeout report reads instead — and it is the better answer anyway: the offending code, not wherever the inlined guard next happened to fire. One immutable pair behind one volatile, so caughtHere reads thread and trace coherently. A materialized trace holds only strings, so retaining it pins no snippet classloader.

Link copied to clipboard
private class GuardState

One lane-thread's watchdog state — everything except that lane's ABI kill-id field.

Properties

Link copied to clipboard
private val BUDGET_MS: Long
Link copied to clipboard
private const val DEFAULT_BUDGET_MS: Long = 1000
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard
Link copied to clipboard

Indexed by the GuardLane ordinal. Fixed length, elements never replaced.

Link copied to clipboard

Functions

Link copied to clipboard
private fun arm(lane: GuardLane)

Arm runs ON the lane thread (from beginFrame/endFrame), which is what lets it hand the watchdog the thread to photograph. disarm deliberately does NOT clear the capture — a report may render after the frame ends, and a stale one can only ever attach to a real later timeout, which arms (and so clears) first.

Link copied to clipboard

Lane pump, start of a frame: arm this lane's budget. Frames nest — see endFrame.

Link copied to clipboard

The stack caught on THIS thread, or null. Matching on the caller's own thread is what keeps a report from picking up the other lane's stack, and makes an off-lane render (a deferred Outcome text built on the HTTP thread) omit the block rather than attach a wrong one.

Link copied to clipboard
private fun disarm(lane: GuardLane)
Link copied to clipboard
fun endFrame(lane: GuardLane)

Lane pump, end of a frame. Disarms only when the OUTERMOST frame ends; an inner frame re-arms a FULL budget for the one it returns to. Vanilla re-enters the pump — Minecraft#disconnect and #doWorldLoad spin runTick(false) while a step is on the stack — and disarming there would leave that step running unguarded for the rest of its turn. Pair with beginFrame in a try/finally, or the depth leaks and the budget is never disarmed.

Link copied to clipboard
fun enterStep(lane: GuardLane, evalId: Int): Int

Lane pump, around one eval's step: publish evalId as who is on this lane's stack, so the watchdog knows who to name. Returns the id it displaced — hand that back to exitStep. Save/restore rather than clear, because steps NEST for endFrame's reason, and the restore is what carries the enclosing step across a nested frame.

Link copied to clipboard
fun exitStep(lane: GuardLane, prevEvalId: Int)

Pair with enterStep in a finally, under the same monitor. Restoring to 0 means the OUTERMOST step ended, and that is where the kill id is cleared: left set, that eval's escaped code would keep reading its own id and throw on every fire from here on.

Link copied to clipboard
fun idle(lane: GuardLane)

Lane pump that runs no frame (empty queue): reset the guard, so nothing a torn-down frame left behind stands until the next eval is submitted — an idle lane has no frame of its own to clear it. No-op inside a frame, for endFrame's reason.

Link copied to clipboard
private fun setKillId(lane: GuardLane, v: Int)

The one lane branch left: a kill id has to be a STATIC field for the woven GETSTATIC to reach it without a frame, so it cannot move into GuardState with everything else.

Link copied to clipboard
Link copied to clipboard

Whether lane's budget is already spent. Read by the pump before it steps the next eval, so an eval that never got its turn can be ended with that reason instead of the offender's. The two reads are coherent without a lock: gen is written only by the lane thread, which is also this method's only caller.