Skip to content

feat(opcache): graph-growing cache serializer (add functions/methods to a cached script) - #269

Merged
lisachenko merged 1 commit into
8.4from
claude/opcache-117-graph-serializer
Aug 20, 2026
Merged

feat(opcache): graph-growing cache serializer (add functions/methods to a cached script)#269
lisachenko merged 1 commit into
8.4from
claude/opcache-117-graph-serializer

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

What this changes

Fixes #117 — stacked on #268. The largest deferred relocator item: serialize a mutated, graph-grown script (not just in-place edits) back to a valid binary.

New src/OpCache/ScriptSerializer.php: a two-pass port of zend_persist_calczend_persist (xlat dedup + aligned sizing; byte-verbatim unit copies + pointer rewrites + deferred late-reference resolution including IS_INDIRECT interior pointers), delegating offset encoding to the existing PayloadRelocator serialize stage. Full 8.4 payload surface covered. BinaryCacheFile::save() auto-routes grown graphs through the serializer and keeps byte-exact derelocate() for in-place edits. Includes a faithful persisted-table hashtable regrowth (persisted data blocks must never be touched by zend_hash_add).

API seam: ReflectionOpcacheFile::addFunctionFrom()/addMethodFrom() graft op_arrays from donor cache binaries — their oplines are already in file form, whereas in-process-compiled op_arrays can't be re-serialized faithfully without unexported engine helpers (that stays the deliberate boundary, refused loudly).

Bonus fix (surfaced by the serializer's byte checks): _ZSTR_HEADER_SIZE is sizeof − 8 (the XtOffsetOf value), not sizeof − 1emitInterned was over-copying 7 bytes per interned string in PayloadRelocator.

Acceptance met: a grafted function (added-fn) and method (added-method-ok) execute from the file cache in fresh workers alongside the originals; all 7 fixture payloads rebuild from scratch and execute; the grown binary round-trips byte-identical.

Environment it was verified on

  • PHP version (full first line of php -v): PHP 8.4.19 (cli) (built: Mar 30 2026 19:28:35) (NTS) + ZTS debug container
  • Thread safety: NTS + ZTS
  • OS / architecture: Linux x86-64 (Ubuntu)
  • Debug build (--enable-debug)? yes — debug84 NTS + debug84-zts, opcache gate 58 each

Also: host 530 tests, opcache gate 58; PHPStan level max clean; cs-fixer clean.

Checklist

  • Targets the minimum affected version branch (chain → 8.4)
  • composer test passes on the matching PHP minor
  • composer phpstan (level max) and composer cs:check are green
  • Tests added or updated; struct fields used already in the generated defs
  • tools/generator/symbols.php unchanged — nothing generated touched
  • Conventional Commits used for the commit messages

🤖 Generated with Claude Code

https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M


Generated by Claude Code

The from-scratch writer sketched in issue #117 as ScriptSerializer: a
two-pass port of zend_persist_calc -> zend_persist (ext/opcache/zend_persist.c,
PHP-8.4.19) fused with the offset-encoding stage of zend_file_cache_serialize.
Pass 1 walks the (possibly mutated) live graph from the zend_persistent_script,
deduplicating every reachable allocation unit through an xlat table (the
zend_shared_alloc_*_xlat_entry port) and summing ZEND_MM_ALIGNED sizes; pass 2
emits a fresh contiguous region - units copied byte-verbatim, every pointer
field rewritten, late references (scopes, prototypes, hook prop_info
back-references, magic-method slots, IS_INDIRECT interior pointers) resolved
against the finished xlat like zend_persist.c's late lookups. The emitted
region is a valid relocated image whose on-disk offset encoding is delegated
to the existing PayloadRelocator serialize stage, so the offset format has
exactly one implementation. Walkers cover the full 8.4 payload surface:
op_arrays (static vars, literals, opcodes, arg_info incl. the arg_info[-1]
return slot, vars, live ranges, try/catch, attributes, dynamic_func_defs),
classes (unlinked and linked-parent, constants, properties incl. hooks,
interface/trait names, aliases, precedences, iterator/arrayaccess funcs),
zvals/arrays/constant ASTs, warnings and early bindings. script->size is
re-stamped (it is the loader's IS_SERIALIZED bound); zend_hash_persist's
sparse-table compaction is deliberately skipped (optimization only) and every
string is region-copied exactly like a file_cache_only child (nothing is
accel-interned there), stamping zend_set_str_gc_flags' interned bits on
sources that lack them.

The API seam: ReflectionOpcacheFile::addFunctionFrom()/addMethodFrom() graft
op_arrays from DONOR cache binaries (compiled by a real opcache child, so
their oplines are already file-form - handler-table indexes and literal-index
operands are not derivable in-process without unexported engine helpers),
regrowing the target hashtable outside the buffer with a faithful
re-implementation of the persisted-table insert (hash slots ahead of arData,
bucket-index chains, HT_SIZE_TO_MASK = -(2*size); persisted data blocks must
never be touched by zend_hash_add). BinaryCacheFile::save() routes grown
graphs through the serializer automatically and keeps the byte-exact
derelocate() path for in-place edits. Whole added classes and in-process
compiled op_arrays remain out of scope and are refused loudly.

Also fixes a latent relocator bug found by the serializer's byte checks:
_ZSTR_HEADER_SIZE is XtOffsetOf(zend_string, val) = sizeof - 8, not
sizeof - 1, which made emitInterned over-copy 7 bytes per emission.

Acceptance evidence (GraphGrowingSerializerTest):
- issue #117 acceptance: a brand-new function AND a new method grafted into
  the cached answer.php execute from the file cache in fresh workers
  ('added-fn', 'added-method-ok'), alongside the original entries; the grown
  binary passes checksum and round-trips byte-identically through the
  relocator
- rebuild coverage: all seven fixture payloads (attributes/statics, type
  lists, traits, closures, property hooks, iterators, jump/const probe)
  re-emitted from scratch execute from the cache and round-trip byte-identical
- refusal paths: unknown donor entries and duplicate keys throw dedicated
  OpCacheException factories
- full default suite (530 tests, baseline skips), --group opcache
  --fail-on-skipped OK (58 tests, 437 assertions) on host, debug84 and
  debug84-zts containers; phpstan level max clean (the two new
  pointer-surgery zones carry the same scoped ignores as PayloadRelocator);
  php-cs-fixer clean

Fixes #117

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M
@lisachenko
lisachenko force-pushed the claude/opcache-119-darwin-relocator branch from 193bd6e to a20e806 Compare August 20, 2026 00:30
@lisachenko
lisachenko force-pushed the claude/opcache-117-graph-serializer branch from 6a4bc8f to 08ed9b7 Compare August 20, 2026 00:30
Base automatically changed from claude/opcache-119-darwin-relocator to 8.4 August 20, 2026 00:42
@lisachenko
lisachenko marked this pull request as ready for review August 20, 2026 00:44
@lisachenko
lisachenko merged commit b7a6d02 into 8.4 Aug 20, 2026
21 checks passed
@lisachenko
lisachenko deleted the claude/opcache-117-graph-serializer branch August 20, 2026 00:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(opcache): support graph-growing mutations in the cache serializer

2 participants