Engine-integrity faults are never guest-catchable
Date: 2026-08-15
Area: runtime, errors, gc
Related: ADR 0105, ADR 0106
Context
Every guest-visible boundary in the engine ends in a generic on E: Exception
arm that turns a Pascal exception into a script value: an Error object handed
to catch, a promise rejection, a recorded test failure. Roughly thirty such
arms exist, across the evaluator, the VM, the promise and await machinery, the
generator continuations, the microtask queue, JSON, fetch, and the testing
library. They were written for the engine's own domain exceptions, and they are
correct for those.
They are not correct for everything that can reach them. A TGocciaValue
collected while a native Pascal local still points at it fails on the next
virtual call — EObjectCheck in a development build, where $OBJECTCHECKS ON
is set in source/shared/Shared.inc, and an unchecked read into reclaimed
memory in a production build. Reaching one of those arms, that use-after-free
becomes an ordinary catch (e), and the script carries on running on top of a
heap the engine has already lost track of. The same is true of an
EAccessViolation from any other source, and of an EInvalidPointer raised
when the heap manager finds its own bookkeeping destroyed.
This is the argument the limit family already makes for
TGocciaMemoryLimitError: a ceiling the guest can catch is a ceiling the guest
can ignore in a loop, so the limit re-raises past every handler to the host.
An integrity fault deserves the same treatment for a stronger reason. A refused
allocation has a defined continuation — the heap is intact, and the script may
free something and retry. A corrupted heap has none.
Decision
Goccia.EngineFault.IsEngineIntegrityFault names a fixed family of exception
classes that mean the process state is no longer trustworthy, and every
conversion boundary opens its generic arm with
on E: Exception dobeginif IsEngineIntegrityFault(E) thenraise;...convert E into a guest value...end;
The bare raise re-raises inside the handler that caught it, so the fault keeps
its original class and its stack all the way to the host. The predicate lives in
its own unit that depends on nothing but SysUtils, so any unit can guard
without acquiring a dependency.
The family is: EObjectCheck (virtual dispatch through a nil or freed
instance), EAccessViolation (an invalid dereference, covering its descendant
EBusError), EInvalidPointer (freeing or resizing a pointer the heap does not
own), EDivByZero, EPrivilege, and EExternalException. EDivByZero is in
the family because no JavaScript operator can produce one: / and % are
IEEE-754 double operations, and BigInt division checks its divisor and throws a
guest RangeError first, so an EDivByZero at a boundary is an engine bug in
native index or size arithmetic.
Boundaries that suppress rather than convert are guarded too. The iterator-close
helpers implement ES2026 §7.4.10 step 5 — an error from iterator.return() must
not replace the abrupt completion that caused the close — with a bare
try..except end. That contract is about close errors, and an integrity fault
is not one, so those handlers now re-raise the family and swallow everything
else exactly as before. The same applies to the RegExp compile boundary, which
converts any compile failure into a guest SyntaxError: it keeps converting
EConvertError, which is how invalid flags are signalled, and re-raises the
family.
Where a boundary does terminal bookkeeping before a re-raise, the integrity guard
does the same bookkeeping. In the testing library that means clearing the
microtask queue and pending fetch completions, and setting the flag that keeps
guest afterEach / onTestFinished hooks from running while the process
unwinds — the hooks would otherwise execute on the unsound heap.
Two deliberate carve-outs
EOutOfMemory is not in the family. It descends from EHeapMemoryError
alongside EInvalidPointer, and the first draft of the predicate tested the
shared ancestor. That was wrong: the two halves of EHeapMemoryError are
opposites. EInvalidPointer means the heap's own structures are broken;
EOutOfMemory means the allocator declined a request and left the heap exactly
as it was. On FPC 3.2.2 a failed SetLength raises a catchable EOutOfMemory,
and the engine's own handlers unwind cleanly through it —
ArrayBufferValue.SetDataLength releases the bytes it had already reserved
before re-raising, so the byte accounting stays true either way. Making it fatal
would also break a realistic guest program: on 32-bit targets the budget cap is
700 MB against roughly 2 GB of address space, so asking for a 600 MB
ArrayBuffer, catching the failure, and continuing with a smaller one is
ordinary defensive code, not a corrupted engine.
EStackOverflow is not in the family. It descends from EExternal
alongside EAccessViolation, but the engine converts it to the guest
RangeError "Maximum call stack size exceeded" on purpose
(PascalExceptionToErrorObject). Recursion depth is a guest-observable limit,
not corruption. No class in the family is an ancestor of it, so it keeps
converting without a special case.
The other exclusions follow the same test — each is raised by the engine on a
path with a defined continuation, so classifying it as an integrity fault would
turn working behavior into a fatal exit: ERangeError (deliberate index-bounds
validation), EIntOverflow (fires only in non-production builds, so making it
fatal would give dev and production different unwind behavior for the same
arithmetic), EConvertError (invalid RegExp flags, malformed TOML integers,
invalid base64 and UTF-8), EInvalidOpException / EInvalidOperation (FFI,
module and engine domain invariants), and EInvalidCast. The authoritative list,
with the reason for each membership and each exclusion, stays in
Goccia.EngineFault.pas and is not duplicated elsewhere.
Host tier: the test runner
Re-raising past every conversion boundary only moves the decision up one level:
it hands the fault to whatever host called Engine.Execute. GocciaTestRunner
is that host for the entire JavaScript suite, and it had the same generic arms
one tier up — one per execution mode, one in the sequential aggregator, one in
the parallel worker body — each of which relabelled an escaped fault as "this
file failed" and moved on to the next of roughly fifteen hundred files. That is
the boundary bug again with a results row in place of the guest's catch: the
suite would go on reporting passes and failures for the rest of the run from a
process that had already lost track of its heap, and the verdict would be
worthless without ever looking wrong.
So the runner aborts the run when IsEngineIntegrityFault holds at one of
those arms, and at the end-of-run inline-snapshot write-back, whose own arm
would otherwise file a fault over the recorded results as one recorded
finalization failure and publish the results anyway. It writes a diagnostic naming the faulting file and the exception
class to stderr under a fixed Integrity fault: prefix, flushes it, stops
dispatching files, and exits 70 — sysexits' EX_SOFTWARE, chosen so it is
distinct from 1 (the suite ran and reported failures) and 2 (the invocation
was unusable) and a harness can tell "these tests failed" from "stop believing
this process" (see CLI
Conventions). The two per-mode
arms only re-raise, which keeps one abort site per tier: the sequential
aggregator for a --jobs=1 run, the worker body for a parallel one. The abort
is deliberately not an unwind: the aggregation would read the very objects the
fault calls into question, and the summary at the end of it would overwrite the
exit code with a pass/fail verdict the process is in no position to give.
In the parallel case a faulting worker does two things, and it is worth being
precise about which one is load-bearing. It cancels the pool's shared queue, so
peer workers stop reaching for new files; then it ends the process from its own
thread. The first draft stopped at the cancel, reasoning that the pool had a
cleaner stop than halting from a worker and that in-flight files could finish
while RunAll returned normally. That is true only while the main thread is
still listening. A worker the watchdog abandoned outlives RunAll, so a fault
in it lands after the main thread has already made whatever check it was going
to make: the cancel reaches a queue nobody is draining, and the run prints a
full summary and exits 0 beneath a stderr line that says it was aborted. No
fixed checkpoint fixes that, because the zombie can report at any later moment
— a second check only moves the window. So the thread that knows ends the
process, and the cancel becomes what it always really was, the orderly half.
How it ends the process is not Halt, and that distinction was paid for.
Halt runs unit finalization on the calling thread before the process dies; on
a worker thread that tears down process-wide RTL state — the thread manager
included — while peer workers are still executing tests. The next threading
operation in a peer then fails with the RTL's own Thread error
(sysconst.SThreadError), the testing library's generic per-test arm converts
it into a recorded test failure, and the abort prints an ordinary-looking red
line for a test that never failed. It reproduced in roughly one aborting run in
seventy-five, and the halt was provably its cause: removing only the worker-side
halt took it to zero in three hundred runs, and forty non-aborting runs of the
same files — each executing every file rather than the handful an abort reaches
— never produced one. The abort therefore terminates through the C runtime
(_exit; TerminateProcess on Windows, which unlike ExitProcess also skips
DLL process-detach handlers that could deadlock on a dead worker's lock),
running no finalization at all. That
removes the window instead of papering over it, and it trusts the suspect heap
less: no finalizer runs on a heap the engine has already said it cannot vouch
for. Buffered stdout dies with the process, which is what an abort wants.
Three smaller mechanics, because the obvious version of each is wrong. The
worker cancels through the pool's cancellation flag rather than the pool
object: the pool is freed while an abandoned worker is still running, and it
leaks the flag rather than freeing it for precisely that reason, so the flag is
the only handle a zombie can safely hold. The main thread keeps a check of its
own on a process-level first-fault flag — not because the abort depends on it,
but because a terminating thread waits for the diagnostic to be written and the
main thread can surface from RunAll inside that window; without the check it
would spend the window printing a summary that is about to vanish. And that wait
is itself the fix to a second defect: the diagnostic is written by whichever
thread wins the first-fault gate, so a loser reaching the exit first would end
the process mid-write and the abort would truncate its own message. That was
seen once per hundred and fifty aborting runs with two or more faulting files,
and never with one — the signature of a race between two threads rather than of
the teardown above. Every thread that terminates now waits for the written flag,
under a bounded timeout so a reporter that dies mid-write cannot hang the abort.
This is one tier's policy, not a guarantee about every exit path. The shared CLI
entry point every Goccia binary runs under (TGocciaApplication.Run) still ends
in a generic arm of its own, so a fault raised outside per-file execution and
the snapshot flush — argument parsing, path expansion, config discovery — is
still reported as an ordinary error and exits 1 with no Integrity fault:
line. Closing that one is a change to every CLI binary at once and belongs to
its own decision.
The limit family keeps its per-file treatment, and the contrast is the argument.
TGocciaMemoryLimitError is uncatchable by the guest for the reason above,
but it is still a verdict on one test file delivered by an intact heap: the file
asked for more than the budget allowed, the runner records the refusal, and the
next file's result means exactly what it says. An integrity fault is not a
verdict on the file at all — it is the engine reporting that it can no longer
vouch for anything, that file's result included. Per-file isolation was rejected
for it on exactly that ground: isolation presupposes the failure is contained,
and containment is the one thing an integrity fault disproves.
Consequences
Embedders can now see these classes escape Engine.Execute. They could always
escape from outside a conversion boundary; what changes is that a fault raised
inside guest execution no longer stops at the first catch. A host that wants
to survive one must decide that for itself — and it should not resume the same
process on the same heap, which is the whole point.
The guard is a class test on an exception that is already being handled, so it costs nothing on any path that does not throw. It is also not a substitute for correctness: the faults it catches are engine bugs, and the right fix is always the missing root or the missing check. What the guard buys is that the bug surfaces as a crash the next run can reproduce, instead of a script that quietly kept going. That is the same trade ADR 0106 made for the hardening programme — find the bugs rather than jail them.
Goccia.MemoryLimit.Test.pas covers both families in both execution modes, with
a native that raises EObjectCheck from inside guest code and asserts the class
reaches the host rather than the script's catch.
The runner's abort has no such automated coverage, and deliberately so: reaching
it needs a fault raised inside a test file's execution, and the only way to
arrange that would be an injection hook in the runner itself — a switch whose
sole purpose is to corrupt a production binary on request. The behaviour was
verified by hand with a temporary local build, in both execution modes and both
--jobs shapes; what CI keeps honest is the surrounding contract, that an
ordinary failing file still exits 1 with an unchanged report.