EngineMemory

by 623f9b12 · generation 2 · every generation passed the checker when published.

Memory lifecycle of a reused wasm-engine isolate behind an RPC wrapper: polled budget stop, post-call reset above a threshold, reset-retry-reset on throw. Checks no polluted instance at request start, isolate never hits the kill limit, bounded budget overshoot, and grow-or-reset memory steps.

Raw .tla Raw .cfg

EngineMemory.tla

MODULE EngineMemory

Isolate lifecycle for the tlc-rs wasm engine, which runs in its own dedicated Cloudflare Worker (tlc-engine), reached from the tlc web worker over a service binding. One engine isolate (one wasm instance) is reused across a sequence of RPC calls, and wasm linear memory only grows, never shrinks. Two defenses added after a heavy check pinned an isolate near the 128 MiB kill limit and poisoned every later request:

- The engine polls memory between batches of states and stops a check gracefully once memory exceeds EngineBudget. Because it polls, memory can overshoot the budget by at most one growth step (MaxGrow) before the stop lands. - The engine worker's RPC wrapper (the "shell" below) re-instantiates the wasm instance (memory drops back to InitMem) whenever memory exceeds ResetThreshold after a call, and on a throw it resets, retries once on a fresh instance, and resets again before rethrowing. A rethrow crosses the service binding as a rejected promise, which the web worker maps to a resource_limit response; the reset protocol itself is unchanged by the two-worker split.

Memory is abstract units. Key invariants: a request never starts on an instance above ResetThreshold (no polluted-isolate observations), and memory never reaches IsolateLimit (the isolate is never killed).

EXTENDS Naturals
CONSTANTS
InitMem, linear memory of a freshly instantiated engine
ResetThreshold, wrapper resets after a call when mem exceeds this
EngineBudget, engine stops a check once mem exceeds this
IsolateLimit, runtime kills the whole isolate at this size
MaxGrow, max memory growth between two engine polls
MaxRequests number of requests hitting the isolate (finiteness)
ASSUMEInitMemResetThreshold
ResetThreshold < EngineBudget
EngineBudget + MaxGrow < IsolateLimit
MaxGrow ≥ 1
VARIABLES
mem, current wasm linear memory, in abstract units
phase, "idle" (between requests) or "checking" (call in flight)
retried, current call already threw and was retried once
reqs requests started so far
vars ≜ ⟨mem, phase, retried, reqs
TypeOK
mem ∈ 0..IsolateLimit
phase ∈ {"idle", "checking"}
retriedBOOLEAN
reqs ∈ 0..MaxRequests
Init
mem = InitMem
phase = "idle"
retried = FALSE
reqs = 0

A new request arrives over the service binding, reaches the reused engine-worker isolate, and enters the engine.

StartRequest
phase = "idle"
reqs < MaxRequests
phase = "checking"
retried = FALSE
reqs = reqs + 1
UNCHANGED mem

The check allocates: memory grows by a nondeterministic bounded amount. The engine keeps running only while its last poll saw mem within the budget, so growth is enabled only when mem <= EngineBudget.

Grow
phase = "checking"
memEngineBudget
∧ ∃ g ∈ 1..MaxGrow : mem = mem + g
UNCHANGEDphase, retried, reqs

The RPC wrapper's after-call reset: re-instantiate when memory exceeds the threshold, otherwise keep the (still small) instance.

ShellSettleIF mem > ResetThreshold THEN InitMem ELSE mem

The call returns: either the check completed within budget, or the engine's poll tripped (mem > EngineBudget, StopReason::Memory) and it stopped gracefully with a resource_limit result. Either way the wrapper's finally-clause reset runs before the isolate goes back to idle, and the result is returned across the service binding.

FinishCall
phase = "checking"
mem = ShellSettle
phase = "idle"
UNCHANGEDretried, reqs

The engine throws mid-call. The wrapper resets to a fresh instance and retries the same request once, all inside the engine worker.

ThrowRetry
phase = "checking"
∧ ¬retried
mem = InitMem
retried = TRUE
UNCHANGEDphase, reqs

The retried call throws too: the wrapper resets again and rethrows; the throw crosses the service binding as a rejected promise, which the web worker maps to a structured resource_limit error.

ThrowFail
phase = "checking"
retried
mem = InitMem
phase = "idle"
UNCHANGEDretried, reqs
Next
StartRequest
Grow
FinishCall
ThrowRetry
ThrowFail
SpecInit ∧ □[Next]vars

Invariants

A fresh request never observes a polluted instance: whenever the isolate is idle (so any StartRequest happens from here, with mem unchanged), memory is at most the reset threshold.

FreshStart
phase = "idle"memResetThreshold

The isolate is never killed: memory stays strictly below the limit.

BelowKill
mem < IsolateLimit

During a call, memory overshoots the engine budget by at most one growth step between polls.

BoundedOvershoot
memEngineBudget + MaxGrow

Action property: memory only ever changes by a bounded growth step or a reset to the fresh-instance size (checked as [][...]_vars).

GrowOrReset
□[∨ mem = mem
mem = InitMem
mem ∈ (mem + 1)..(mem + MaxGrow)]vars

EngineMemory.cfg

SPECIFICATION Spec
CONSTANTS
InitMem = 1
ResetThreshold = 3
EngineBudget = 6
IsolateLimit = 12
MaxGrow = 2
MaxRequests = 4
INVARIANTS
TypeOK
FreshStart
BelowKill
BoundedOvershoot
PROPERTY GrowOrReset
CHECK_DEADLOCK FALSE

Generations

genchangesdistinct statesdepthpublishedraw
2 (latest) Engine moved behind a service binding into its own worker; comment-only update, state machine unchanged. 89 13 2026-07-23 23:04:22 UTC .tla .cfg
1 Model the new memory budget + instance reset added after prod isolate-pollution failures 89 13 2026-07-20 23:40:47 UTC .tla .cfg

Defend this spec

Ask an AI role-playing the spec's author to defend the design, dissertation-style. This site holds no AI keys: you grant a small revocable budget from your own tokenpony.dev balance (or any TPX provider you choose) and your browser talks to the model directly.

Loading…