Eval Runtime Architecture
How literal eval is planned for AOT lowering, when Magician is linked, and how native and interpreted scopes share values.
Sources: src/eval_aot.rs, src/ir_lower/expr/mod.rs,
src/ir_lower/program.rs, src/codegen/lower_inst/builtins/eval.rs,
src/codegen_support/runtime/eval_bridge.rs,
src/codegen_support/runtime/eval_scope.rs, and
crates/elephc-magician/.
Eval is an experimental hybrid feature. Ordinary elephc source and eligible literal eval fragments are compiled ahead of time. Only fragments that require runtime parsing or dynamic symbol behavior use the optional Magician interpreter. The resulting executable remains standalone: Magician is a static bridge library, not an external PHP or Zend dependency.
For PHP-visible behavior and the supported fragment subset, see Eval. This page documents the compiler/runtime boundary.
Pipeline position
eval is not a dedicated lexer token or AST node. The parser produces an
ordinary ExprKind::FunctionCall; later semantic passes recognize the
case-insensitive PHP language-construct name.
eval($code)
-> checker: exactly one argument, result Mixed, conservative barrier
-> EIR lowering: LanguageConstructCall or EvalLiteralCall
-> literal planner, when the source is statically known
-> internal no-scope EIR function
-> internal EIR function with direct read parameters
-> internal scope-aware EIR function plus core eval-scope helpers
-> Magician interpreter fallback
-> target-aware assembly and optional bridge linking
The front end reads a parsable source literal as inline code: reachability,
prelude pruning and the throwable, SPL and Reflection checker gates keep what
the fragment names and drop the rest, exactly as for the same statements written
outside eval. Only a runtime-sourced or unparsable eval, or a dynamic hazard
inside the fragment (a computed call or callable, new $c, include, a
namespace), keeps the previous whole-surface behaviour. The AOT decision itself
still happens during EIR lowering, after the front end has preserved PHP’s
dynamic semantics and diagnostics.
Execution-path decision
| Path | Typical input | Runtime requirements |
|---|---|---|
| No-scope AOT | A literal fragment with no caller-scope access | Internal EIR function; no eval context, scope, or Magician library |
| Direct-read AOT | A statically lowerable literal with read-only caller values | Internal EIR function with boxed Mixed parameters; no eval scope or Magician library |
| Scope-backed AOT | A statically lowerable literal with known scope writes | Internal EIR function plus core eval_scope; no interpreter library |
| Interpreter fallback | A dynamic string or a literal requiring dynamic declarations, includes, references, dynamic calls, or another unsupported AOT shape | eval_bridge, synchronized scopes, and elephc_magician; optional capabilities such as regex are linked separately |
src/eval_aot.rs parses literal fragments at compile time, applies call-site
magic-constant metadata, records known scope reads and writes, and produces an
EvalAotPlan. A plan can contain a no-scope EIR body, a scope-aware EIR body,
or a conservative fallback reason. src/ir_lower/program.rs materializes each
accepted body as a deterministic __eir@evalaot* function before validation,
optimization, register allocation, and normal target-aware codegen.
Current fallback classes include parse failures, include/require, runtime
declarations, global/static scope, references and by-reference operations,
dynamic calls or class/member resolution, unsupported object/array/iterable
shapes, try/throw, unsupported control flow, and unsupported static calls.
Eligibility is intentionally conservative: a fragment falls back rather than
being partially compiled with different observable behavior.
Runtime-created throwables
An eval() declaration can introduce a class that the compiler cannot resolve
at compile time. A later AOT throw of an instance of that class therefore
defers the Throwable check to runtime. The emitted path validates the boxed
Mixed value against the runtime Throwable interface before unwinding. For
a valid throwable it moves the object owner out of the Mixed box into the
active exception slot and releases the box; for an invalid value it releases
the box before raising a catchable Error. A non-object raises
Can only throw objects; an object that does not implement Throwable raises
Cannot throw objects that do not implement Throwable. This ownership transfer
is shared by statement-form and expression-form throws. A runnable program is
examples/eval-throw/.
src/ir_lower/program.rs repeats the final bridge-requirement check against the
completed EIR module. This accounts for actual local-slot types and supported
static function/method targets before setting RuntimeFeatures::eval_bridge.
Consequently, the presence of EvalLiteralCall alone does not imply that the
binary links Magician.
EIR representation
Literal calls use EvalLiteralCall, carrying the fragment in the module data
pool. Dynamic calls remain compiler-resident LanguageConstructCall operations
until the eval lowering path materializes the runtime code string. Registry-backed
builtins use typed RuntimeCall targets instead and never participate in eval-name
dispatch.
The eval-specific EIR operations are:
| Operation | Responsibility |
|---|---|
EvalLiteralCall | Preserve a literal fragment for AOT planning or interpreter fallback. |
EvalScopeGet, EvalScopeSet | Read or update a named boxed cell in a materialized eval scope. |
EvalFunctionCall, EvalFunctionCallArray | Dispatch a function created or registered in the persistent eval context. |
EvalFunctionExists, EvalClassExists | Probe dynamic symbols that may have been created by an earlier eval barrier. |
EvalConstantExists, EvalConstantFetch | Probe or fetch constants retained in the eval context. |
EvalObjectNew | Construct a class that may have been declared at runtime. |
EvalStaticMethodCall | Dispatch a static method whose target may come from eval metadata. |
The conservative default effects for eval calls include arbitrary observable call effects. Symbol probes read global state; scope access reads or writes heap state and can fail; constant fetches also carry ownership/refcount effects. Later lowering may refine a literal call after the AOT plan proves a narrower path.
Three addressable local kinds hold eval state when required:
LocalKind | Lifetime and role |
|---|---|
EvalContext | Persistent Magician context for declarations, constants, callable metadata, and interpreter state. Its presence requires the full bridge. |
EvalScope | Materialized activation/closure scope shared with the executing fragment. |
EvalGlobalScope | Materialized program-global scope used by global aliases and CLI argument globals. |
Frame sizing and cleanup see these slots before assembly emission. A no-scope
or direct-read AOT fragment does not declare them merely because the source
contains eval().
Checker and optimizer barrier
The type checker enforces exactly one argument, infers that argument for its
side effects, and gives the call the static type Mixed. After the call it:
- marks the active statement stream as having crossed eval;
- widens known local types to
Mixed; - drops closure, callable-signature, capture, and callable-target facts;
- permits later reads of variables and dynamic symbols that eval may have created.
AST constant propagation reports Invalidation::All for eval. This prevents a
pre-call constant or alias fact from being reused after code that can create,
overwrite, or unset caller-visible state. EIR planning may later omit the
physical runtime barrier for a proven literal path without weakening those
front-end safety rules.
Dynamic bridge lifecycle
When interpreter fallback is required, generated code performs these steps:
- Coerce the code argument to a PHP string.
- Lazily allocate the activation’s
EvalContexthandle and attach the module’s immutable native catalog on a cache hit. A cache miss leaves the catalog unpublished until a later guard requires it. - Set the strict-PHP/version profile and call-site file, directory, namespace,
class, trait, function and method metadata. Ensure the activation
EvalScopeand, when needed,EvalGlobalScopehandles. - Compute the fragment’s conservative caller-scope access mask. Ask
__elephc_eval_context_can_flush_without_native_catalogwhether replacing existing local and global cells is safe and whether the activation still has a numeric-deferral budget. Otherwise, register and publish the complete native catalog before flushing any scope owner. - Flush selected locals and globals or the complete eligible scope into boxed cells, mark global aliases and attach the global scope to the context.
- Ask
__elephc_eval_context_can_defer_native_catalogwhether the parsed fragment and current scopes can execute safely without native metadata. If not, register and publish the complete catalog before executing PHP. Both guards share one registration block and an already published catalog is reused. Registration includes AOT functions, methods, constructors, class metadata, constants, parameter names, defaults and visibility information. - Push the caller class scope when required and call
__elephc_eval_execute_with_deferred_catalogthrough the target-aware ABI, then restore that class scope. - Reload dirty, created or unset scope entries and propagate return/fatal/ throwable state through the normal generated runtime paths.
A bounded proof permits the first 512 straight-line numeric scalar evals in
one activation to execute while the native catalog remains unpublished. The
next eval uses the existing full-catalog path before flushing scope owners,
limiting repeated proof work in long loops. The saturating execution counter
belongs to the activation and is not shared with the immutable catalog.
Class imports are postponed only while the catalog is unready; an earlier
metadata-dependent fragment upgrades the same activation immediately. The
legacy __elephc_eval_execute entry retains eager class imports for existing
bridge consumers.
Selective dynamic scope synchronization
Before crossing into Magician, generated code asks the parser or parse cache for a conservative 64-bit mask of caller-scope names used by the fragment. The parser and native code use the same FNV-1a name hash. Each set bit gates both the pre-eval flush and post-eval reload for that name. Hash collisions can only cause an additional name to be synchronized.
Selective synchronization is restricted to direct numeric scalar arithmetic
and assignment expressions. Calls, strings, references, variable variables,
arrays, objects, $GLOBALS, includes, handlers, introspection, and every other
syntax class select the complete scope. Parse failures and fragments larger
than 64 KiB also select the complete scope. Once a context encounters one of
those cases, the full-scope requirement remains active for that function
activation. A later activation creates a fresh context.
A selected Mixed or union binding keeps the narrow mask only when its current
runtime value is an integer, float, or boolean. Other tags promote that call to
full synchronization. Throwable writeback reloads the selected or complete
scope before native propagation, using the same mask as normal completion.
Generated modules own a private, aligned, zero-initialized catalog token. The
bridge assigns it a process-unique generation and retains up to eight catalogs
per thread, ordered by last use. Reloading an image initializes a new token and
cannot select callbacks from an older generation. Eviction removes the cache’s
ownership; already attached contexts keep their metadata alive. Registration
failures disable publication for that context. Valid metadata replacements keep
the registration ABI’s legacy zero result and are distinguished internally from
rejected inputs. Legacy bridge consumers using
__elephc_eval_context_new continue to register independently.
A cache hit shares Rust-owned descriptions and module-lifetime callback addresses without cloning signature maps. The cache contains no runtime cells, scopes, dynamic declarations, reference targets, handlers or materialized parameter defaults. Those remain activation-local. Native metadata writes and appending dynamic declared names use copy-on-write for the affected collection. Opaque descriptors never cross threads through this cache. Module callbacks and default factories must remain loaded while a context using them is live.
Before native writeback, Magician validates returned values and visible scope graphs for restricted foreign PCNTL callables. Each context has a sticky capability set when a foreign callable is retained or copied, or a foreign-context closure target is registered. Contexts that have never acquired that metadata skip the graph walk and scope-cell collection. Once set, the capability stays active through handler replacement and pin cleanup. Full validation then covers cyclic containers, globals, detached handlers, and scope writes before a throw. Ordinary scope synchronization and throwable propagation use the same paths.
Magician has no direct PCRE2 symbols in its base static library. If the final
EIR module requires the regex runtime, generated eval setup registers the
managed elephc_pcre2_v1_* shim callbacks before creating the context.
Magician then exposes its regex builtin area through that opaque provider. With
no provider, preg_* names are absent from dynamic eval lookup and calling one
fails at runtime. Opaque dynamic source can opt into that capability with
--with-regex; visible static regex use enables it through normal feature
detection.
Top-level scope setup also seeds $argc and $argv. Function fragments can
bind those values or compiler-known program globals with PHP global aliases.
By-value closure captures synchronize only their captured copy; by-reference
captures share a ref cell whose storage is widened before it crosses the
barrier when necessary.
Shared value ABI and ownership
Magician does not introduce a second PHP value layout. The generated runtime
exports C-ABI hooks that box, unbox, retain, release, compare, cast, iterate,
and mutate the same Mixed cells used by native code. Array writes still pass
through the normal copy-on-write helpers, and object/class operations reuse
generated metadata when the bridge shape is supported.
RuntimeCellHandle also carries Rust-only provenance. A handle returned from
durable context, scope, constant, property, or class storage is borrowed; a
handle created by a constructor, copy, retain, or ownership-transferring result
API is owned. This marker never crosses the C ABI. Raw argument and mutable
reference slots contain contiguous RuntimeCell * values, and FFI entrypoints
reconstruct raw pointers with owned provenance by default. APIs that transfer
one owner to Magician keep that default; FFI entrypoints receiving caller-owned
inputs explicitly convert the reconstructed handle to borrowed provenance.
Runtime string contexts that encounter an eval-declared object use
__elephc_eval_string_context. The bridge returns the same ElephcEvalResult
value-or-throwable shape as method calls: successful strings are persisted while
the formatter copies them, and escaped __toString() exceptions enter the native
__rt_throw_current path so surrounding compiled try/catch blocks remain
authoritative.
Scope setters retain the value stored in the context; getters expose borrowed handles. An expression consumer that requires an owned PHP value copies that borrowed cell through the shared scope-copy helper. Arrays retain their cursor and alias side metadata while receiving an independent copy-on-write cell; objects and resources preserve their PHP identity. Normal returns, runtime fatals, thrown values, early fragment returns, and function cleanup must all balance those cells. Persistent declarations and metadata live in the eval context until its owning generated function or process scope is destroyed.
Cycle collection uses __elephc_eval_gc_collect_cycles_v2(Throwable **out):
native exceptions are caught below the Rust activation and returned as an owned
boxed Throwable. The destructor callback installed through
__elephc_eval_install_dynamic_object_destructor_hook_v2 likewise receives a
Throwable output slot, returning zero for a missing target, one for success,
or two for an escaping exception. Native code propagates that exception only
after the callback returns from Rust. These versioned symbols have no legacy
aliases, so incompatible prebuilt Magician archives fail at link time instead
of calling a different argument ABI. The eval-context layout version is separate.
The Rust-only RuntimeCellHandle records whether an expression borrows a stored
cell or transfers an owner. Native C-ABI arguments and by-reference slots remain
raw cell pointers, never the Rust handle structure. Core adapters and scalar
expression consumers retain borrowed operands before evaluating later operands,
then release their leases on success and failure. Conditions consume their
temporary results; assignments transfer an owner and balance replacements even
when the old and new cell pointers coincide. Class-default collection builders
also acquire a lease for defaults borrowed from persistent class constants or
enum cases before inserting and releasing their temporary operands.
By-value foreach likewise owns its evaluated receiver until iteration finishes.
The receiver lease survives body callbacks and replacement of its source local,
then retires on normal completion, break, return, or failure. Literal temporary
arrays do not depend on a scope binding to keep their owner balanced.
Builtin lookup is also shared at the contract boundary. Magician joins its
implementation hooks to the same BuiltinId used by the compiler. For compatible
boxed-cell operations it dispatches a typed RuntimeBuiltinId through
__elephc_runtime_builtin_call_v1; arguments are borrowed and a successful result
transfers one fresh cell through result_out. Unknown IDs and unsupported arities
fail closed. The compiler emits target-aware wrappers for macOS ARM64, Linux ARM64,
and Linux x86_64. By-reference/lvalue, callable, reflection, resource,
eval-declaration, and partial-signature behavior remains on an explicit Magician
adapter with a catalog-audited reason.
Reflected AOT member names cross the same boundary as generated data tables, and
those tables store the declared spelling, not the case-insensitive lookup
key. _eval_reflection_methods is emitted from each class-like’s method_decls
(src/codegen_support/runtime/data/user.rs), so __elephc_eval_reflection_method_names
hands the interpreter Match for a method written Match. Every reader of the
table compares with __rt_strcasecmp, which is what makes that safe: the stored
spelling decides only what is reported, never which row a lookup finds. The
interpreter must therefore not re-normalize a name it took from the table — doing
so is how ReflectionMethod::getName() inside eval() came to answer match
while the same program’s compiled reflection answered Match (issue #571).
Eval array reads use a dedicated owned shared-cell mode. Unlike an ordinary PHP array read, which detaches a boxed zval to preserve value semantics, the bridge must retain the exact stored cell because that handle can be the writeback target for an AOT by-reference method, constructor, reflection, or callable invocation. This mode is separate from the nested-write fetch, which COW-normalizes the outer container and detaches the selected zval before mutation.
Runtime generic specialization
crates/elephc-generics owns structural type terms, conflict detection, written argument binding,
dependent defaults, and the specialization depth limit shared by the compiler and Magician.
Compiler adapters live in src/generics/shared.rs; runtime dispatch lives in
crates/elephc-magician/src/interpreter/generics/ and its parent module.
The Magician parser retains an EvalGenericTemplate containing a declaration prototype, lexer
tokens, lexical imports/namespace, and the token indexes of actual type-parameter uses. Replay
substitutes those positions only, preserving unrelated identifiers and literal bytes. Constructor
slots have separate lexical identities so promotion and shadowing remain correct.
Class-reference type arguments are consumed only after lookahead parses a complete list;
ordinary comparisons after instanceof or new remain expression operators.
PHPDoc inheritance edits match the complete written parent name within the tag’s extends or
implements clause, with at most one inserted list per parent and native arguments taking priority.
Runtime dispatch borrows arguments after their single source-order evaluation, completes bindings,
checks bounds, and replays a concrete ordinary declaration. Functions and classes enter the normal
context registries; method specializations are cached by declaring class and concrete method name.
Eval and included-program entry share strict-PHP checks, CLI-global seeding, and direct
template hoisting through execute_program_statements.
Nested statement lists register and validate templates only when execution reaches their
declaration, preserving return, throw, break, and continue behavior and redeclaration errors.
Constructor inference omits null and empty arrays using concrete runtime tags, so zero-property
native objects still contribute their class types.
Class relation registration instantiates generic parents, interfaces, and traits before ordinary
validation. A recursion guard rejects cyclic generic inheritance, and type growth is bounded.
Signature compatibility projects concrete bindings through class and interface ancestors,
including specializations that have not yet entered the runtime registry. Variance checks
run again before specialization or cached class access, using the current dependency registry
so later trait, class-parent, or interface declarations cannot invalidate an earlier promise.
They include public and protected trait members and nested trait uses, with method-local slots
shielded from enclosing template substitutions. Qualified adaptations resolve against the
complete trait-use list before the effective imported method surface is checked.
Class identities put class and constructor slots in a single argument list. Constructor slots
remain part of the registry key; class-shaped inference and variance compare only class slots.
Written lists extending beyond class slots bind the combined class/constructor parameters, so
concrete names and parent references resolve independently of specialization cache order.
Failed initialization releases the allocated receiver on property-default, argument-binding, or
constructor errors and suppresses its destructor. An escaped $this retains class/property
metadata until its remaining owners release it, matching PHP’s failed-construction behavior.
Generated-code context calls and callable dispatch use the same specialization resolver before
ordinary binding. The public context-call entry points live in interpreter/context_calls.rs;
first-class function callables may retain a template name until arguments determine its slots.
Structural EvalParameterTypeVariant::Generic contracts validate boxed arrays and typed callbacks
at existing argument and return boundaries. Indexed contracts reject associative storage. Typed
local writes validate before mutation, including nullable and union array arms, normalized keys,
and the existing contents of a selected union arm. Local reference groups collect every declared
contract. Persistent wrappers carry those contracts in context/local_types.rs, so aliases,
reference parameters, captures, and returned array references enforce the same constraints.
The metadata borrows runtime storage and uses allocation retirement tokens to prevent contracts
from following reused addresses. Rejected typed declarations restore the previous local contract,
and property writes preserve shared wrappers while validating before mutation.
Replacing a wrapper’s value refreshes its element aliases and cursor metadata from the RHS
before releasing the previous payload; a replacement without metadata clears the old entries.
Array-element writeback copies the live receiver’s metadata onto its shallow clone before
replacing shared storage, including nested Cell targets. Updating one element preserves
sibling references and the current or exhausted array cursor.
Independent value copies retain their ordinary COW semantics.
Float and bool keys use a temporary integer
cell for contract validation; that cell is released on success and failure, while storage receives
the original key so native normalization and diagnostics keep their existing operands. No new
value representation or target-specific ABI is introduced. PHPDoc token provenance preserves strict
mode acceptance; EvalProgram records native syntax for rejection before execution. The current
file-cache layout includes retained templates, tokens, imports, structural types, typed locals,
and the conservative caller-scope access mask, alongside preserved source spelling for direct
and first-class function calls, native deferred-default error expressions, and retained readonly
setter contracts.
FORMAT_VERSION in crates/elephc-magician/src/script_cache/file_store.rs identifies the complete
serialized layout; incompatible entries are rejected before decoding. Format changes invalidate
older generic and ordinary eval entries, including incorrectly rewritten PHPDoc parents and
member annotations discarded after uppercase or mixed-case native class-like keywords.
Earlier format changes also invalidated stored parse errors for valid comparisons after class references.
The format guard covers the combined serialized IR and its corresponding type sources.
Native callable defaults retain array-unpack operands through a private array-element marker.
Registration validates and normalizes that marker before execution; it is not public PHP syntax.
The interpreter evaluates each unpack operand once, appends numeric entries, overwrites string
keys, and releases partial results if evaluation fails. Generated code checks registration results
before installing a default factory. Native class-initialization errors have a separate catalog
entry from reflected property defaults: static access checks visibility first, then initialization,
without invalidating an independently readable ReflectionProperty default.
Parsing and cache
Dynamic source is parsed into Magician’s immutable EvalIR. The process-wide parse cache stores both successful parse results and parse errors by exact source bytes:
- FIFO capacity: 256 distinct fragments;
- maximum cacheable fragment size: 64 KiB;
- cached data: immutable
Arc<EvalProgram>orEvalParseErroronly; - excluded data: scopes, cells, declarations, context, and call-site magic constant values.
The same cache is used by the public eval FFI entry and nested eval
execution. Large one-off fragments bypass it instead of occupying global cache
capacity. Included files have their own memo (parse_script_cached), with the
same cap: a file starts in HTML mode and a fragment in PHP mode, so the same
bytes parse to different programs and must never share a key.
Runtime script cache
Sources: crates/elephc-magician/src/script_cache/, the
__elephc_eval_*opcache* symbols in crates/elephc-magician/src/ffi/context.rs,
and src/opcache_prelude/.
A PHP file included at run time — a path only eval() can reach — is the one
part of a binary that is not compiled at link time. The runtime script cache is
OPcache’s cache for those files; what OPcache reports about compiled code is
emulated from the compile-time manifest instead. Every piece of it is inert
unless the cache is enabled for the binary (ScriptCacheConfig::enabled, the
same predicate as opcache_cache_enabled), so a default CLI binary caches
nothing and behaves as it did before the cache existed. The user-facing rules
are in OPcache; the invariants below are what the
implementation must keep.
The unit and the load path
The unit is the FILE, keyed by its canonical path. segments::parse_script
parses a whole file as PHP compiles it: the tags cut it, inline HTML becomes
echo of its exact bytes, and the pieces join into one token stream parsed
once, so a block may span the tags and a parse error anywhere fails the whole
file. An entry holds that one program (or the parse error).
store::load_script serves a warm entry, or fills one in this order, which is
php-src’s and is pinned by tests:
- Open the file ONCE and take the bytes, the mtime and the size from that
handle, so a rewrite between a read and a
statcannot pair new metadata with old bytes. - Consult the on-disk cache first: its refusals decide what may be COMPILED into the cache, not whether a stored entry may be served.
- Refuse, in order: the blacklist (
blacklist_misses, stored nowhere), a timestamp that cannot be recorded or is younger thanopcache.file_update_protection(misses), thenopcache.max_file_size(blacklist_misses). A refused script still RUNS. - Never cache a file that did not parse, in memory or on disk: a fixed file must not keep raising the old error.
With the cache disabled, nothing is stored and the file memo above keeps a repeated include off the parser.
Freshness
A fill records revalidate_at = request_time + opcache.revalidate_freq, and
the file is stated again only once that has passed; validate_timestamps=0
never re-stats. Validation compares the TIMESTAMP ALONE, as php-src does. A
failed revalidation marks the entry discarded BEFORE the fill decides on the
replacement, so a refused replacement never leaves the old version live. The
request time is fixed per request: __elephc_eval_configure_opcache stamps it,
once in the CLI prologue and once per --web request.
Capacity
The cache NEVER evicts. Once the opcache.memory_consumption byte budget (the
sum of the entries’ memory_footprint, their source length) or the entry
ceiling (opcache.max_accelerated_files rounded to php-src’s prime, less the
scripts compiled into the binary) is reached, it refuses new entries and
latches cache_full. A forced opcache_invalidate() marks an entry
discarded rather than removing it, as php-src keeps the slot until a restart.
Restarts and request boundaries
opcache_reset() only SCHEDULES a restart: within the request the cache keeps
answering and every counter stays put. __elephc_eval_opcache_apply_restart,
which generated code emits at the top of each --web request, performs it and
drops the request’s ini_set() overrides. A CLI program is one request and
never flushes, which is what reference PHP does with no next request.
Configuration is installed at startup by generated code
(__elephc_eval_configure_opcache, then __elephc_eval_opcache_swap_directive),
not when the first eval context is built, so every call in the program sees the
same configuration. The blacklist is loaded once per process.
The on-disk cache
file_store implements opcache.file_cache. Invariants:
- An entry lives at
<file_cache>/<crate version>-<FORMAT_VERSION>/<FNV-1a of the path bytes>.bin. Its header carries a magic, the format version, the canonical path’s raw bytes, the mtime, the size and the writer’srevalidate. A read checks the header on the raw bytes before decoding, bounds the decode by the file size, and treats every failure as a miss. - Identity is unconditional (the path bytes must match); freshness is checked
only under
validate_timestamps, and a stale entry is removed by the read that rejects it. - A write goes to an
O_EXCLtemporary under a random name, then a rename.opcache.file_cache_read_onlywrites nothing and deletes nothing. - bincode is not self-describing, so an entry written for another EvalIR
shape can decode into plausible garbage.
format_guardfingerprints the code lines ofeval_ir/*.rs,segments.rsanderrors.rs; when it fails, bumpFORMAT_VERSIONand re-record the fingerprint. A change to the stored header (CacheFile, infile_store.rs) is invisible to the guard and needs a manual bump. Never reuse a version number that has been published.
The native bridges
The natively compiled opcache_* functions reach this cache through the
internal __elephc_opcache_rt_* builtins, backed by the FFI symbols above, so
native code and eval() give the same answers. With no eval bridge linked,
each call folds to 0 at lowering time; opcache_compile_file(), whose job is
to create an entry, links the interpreter by itself.
Configured binaries supply every OPcache declaration for interpreter-capable eval, including literal fragments with runtime-selected callable names. The shared AOT planner excludes native and scope-only literals from this broad injection, so those fragments do not acquire a dynamic cache or file-cache startup validation.
Free-function registration carries the declaration’s compiler-internal origin in bit 2 of the existing signature flags word. AST source mode sets the EIR function flag, and the target-aware registration preserves it for Magician. OPcache internal argument binding applies to generated preludes and fallback handlers; functions declared by the user keep their own signatures and reference semantics. Legacy registrations without the origin bit keep user semantics.
Cached-script snapshots retain native PathBuf values. The borrowed-string FFI
copies their encoded bytes into its thread-local buffer without a UTF-8
conversion, so distinct Unix filenames remain distinct status keys and the
reported paths can be passed back to invalidation.
The integration suites are tests/opcache_runtime_cache_tests.rs,
opcache_file_cache_tests.rs, opcache_blacklist_tests.rs and
opcache_strict_invalidate_tests.rs; unit tests sit beside each module
(store_tests.rs, file_store.rs, segments.rs). Each rule above was
measured against reference PHP, and the comment at each site records the
measurement.
Linking and targets
RuntimeFeatures::eval_scope emits only the core scope helpers.
RuntimeFeatures::eval_bridge additionally links libelephc_magician.a, but
not PCRE2. RuntimeFeatures::regex independently resolves managed PCRE2 and
causes eval setup to register the provider callbacks. The bridge is registered
in src/linker/ as --with-eval with the optional
ELEPHC_MAGICIAN_LIB_DIR archive-directory override. Normal compilation
derives both features independently; --with-eval force-loads the archive,
while --with-regex force-enables the regex runtime for opaque source. Neither
flag alters AOT eligibility.
All eval lowering and bridge ABI paths are target-aware and covered by dedicated integration shards on macOS ARM64, Linux ARM64, and Linux x86_64. Shared lowering must use the existing ABI helpers rather than assume a specific register set or object format.
Documentation ownership
The exhaustive language subset, reflection behavior, builtins, and known gaps
belong in Eval. Per-builtin AOT/eval availability is generated
from the shared elephc-builtin-contract catalog joined to the builtin! and
eval_builtin! backend registries in the
Builtin Reference. The Runtime documents
the shared __rt_* assembly families and links here for the optional eval
boundary.