Skip to content

fix: exception-safe engine-state restoration (fake scope, pDestructor, class-table guards) - #197

Merged
lisachenko merged 4 commits into
8.4from
claude/php-repo-modernization-6pfu7y-exception-safety
Aug 13, 2026
Merged

fix: exception-safe engine-state restoration (fake scope, pDestructor, class-table guards)#197
lisachenko merged 4 commits into
8.4from
claude/php-repo-modernization-6pfu7y-exception-safety

Conversation

@lisachenko

Copy link
Copy Markdown
Owner

Four related fixes where engine-global state could be left corrupted if an operation threw halfway through, plus one stale-cache read.

1. EG(fake_scope) is never restored when the original handler throws

Seven property hooks paired setFakeScope() calls by hand:

$previousScope = Core::$executor->setFakeScope($object->ce);
$result        = ($originalHandler)(...);
Core::$executor->setFakeScope($previousScope);

Any throw from the original handler — a throwing __get(), a typed-property error, an uninitialized readonly access — skips the restoring call and leaves EG(fake_scope) pointing at a foreign class entry for the rest of the request, silently changing every visibility check that follows.

Executor::withFakeScope(?CData $scope, Closure $body) (src/System/Executor.php) now owns the install/restore pair and restores in a finally. All seven call sites go through it, so the unsafe pattern cannot be written again:

  • src/ClassExtension/Hook/ReadPropertyHook.php
  • src/ClassExtension/Hook/WritePropertyHook.php
  • src/ClassExtension/Hook/HasPropertyHook.php
  • src/ClassExtension/Hook/UnsetPropertyHook.php
  • src/ClassExtension/Hook/GetPropertyPointerHook.php
  • src/ClassExtension/Hook/GetPropertiesForHook.php
  • src/ClassExtension/Hook/GetDebugInfoHook.php

The baselined argument.type entries for those call sites are renamed to the new callee — same pre-existing CData typing gap, no new baseline entries.

2. Hand-rolled pDestructor disable/restore duplicating HashTable::deleteWithoutDestructor()

Three sites reimplemented the "null out pDestructor, delete, put it back" dance that HashTable::deleteWithoutDestructor() (src/Type/HashTable.php:299) already performs with a finally:

  • src/Reflection/ReflectionMethod.php (fromHookCData) did it inside its finally block with no try, so a failing delete() left the publication board's function table with a NULL destructor for the rest of the process — every later bucket removal there would leak. The now-unused $boardTable = $methodTable->getRawValue() handle is dropped with it.
  • src/HotSwap/HotSwap.php (unpublishClassEntry) duplicated the logic; ClassDelta already used the helper, so this just follows the existing precedent.
  • src/Core.php (shutdown()) duplicated it around the generated-function unpublish loop.

Semantics are unchanged — the destructor is still disabled for the delete, so the payload (a shared zend_function, a rehomed class entry, a function embedded in an immortalized closure) survives the bucket removal. The find() !== null pre-check in Core::shutdown() is kept: zend_hash_del() reports an absent key as FAILURE, which HashTable::delete() turns into a RuntimeException, so the guard is load-bearing.

3. Missing null guard in ReflectionClass::addInterfaces()

src/Reflection/ReflectionClass.php called getRawClass() on the result of HashTable::find() without checking for null. The interface_exists() pre-check is not sufficient: it accepts a leading-backslash name ("\Countable") that strtolower() never turns into a class-table key, so the lookup misses and the method dies with a call-on-null instead of a ReflectionException — halfway through, with a freshly allocated interface buffer already tracked.

Adds the same guard every sibling lookup carries (setParent(), ObjectEntry, ClosureEntry, …). This retires one baseline entry, removed by hand from phpstan-baseline.neon.

4. Stale shadow property in ObjectEntry

src/Type/ObjectEntry.php cached a HashTable wrapper over zobj->properties once in initLowLevelStructures(), but setDynamicPropertiesPointer() rewrites that very field without touching the shadow copy. The only reader, __debugInfo(), therefore dumped a table the object no longer owns — possibly one already freed, i.e. a dangling read from a debug dump. Objects whose table was built after the entry (the common lazy case) were missing from the dump entirely.

The field is removed and the table resolved from the live pointer at dump time, so ObjectEntry keeps exactly one source of truth for zobj->properties.

Validation

Local test execution was impossible in this container: it runs PHP 8.5.9 while this branch targets PHP 8.4. Per AGENTS.md's non-negotiable version-matching rule, nothing that reaches Core::init() may be executed against a mismatched minor, so no test — and no code touching engine memory — was run here. CI must provide the test signal for this PR.

Validation performed was static only, and both gates are clean:

  • composer phpstan (level max) — no errors
  • composer cs:check (php-cs-fixer, @PER-CS2.0) — no fixable files

No generated artifacts (include/, stubs/, .phpstorm.meta.php) were touched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG


Generated by Claude Code

claude added 4 commits August 13, 2026 19:14
The property hooks paired setFakeScope() calls by hand around the original
engine handler. Any throw from that handler - a throwing __get(), a typed
property error, an uninitialized readonly access - skipped the restoring call
and left EG(fake_scope) pointing at a foreign class entry for the rest of the
request, silently changing visibility checks everywhere.

Executor::withFakeScope() now owns the install/restore pair and restores in a
finally block; all seven affected hooks (read/write/has/unset property, get
property pointer, get properties for, get debug info) go through it, so the
unsafe hand-rolled pattern can no longer be written.

The baselined argument.type entries for the hook call sites are renamed to the
new method - same pre-existing CData typing gap, new callee name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
Three sites hand-rolled the "null out pDestructor, delete, put it back" dance
that HashTable::deleteWithoutDestructor() already performs safely:

- ReflectionMethod::fromHookCData() did it inside its finally block WITHOUT a
  try, so a failing delete() left the publication board's function table with a
  NULL destructor for the rest of the process - every later bucket removal
  would then leak. The now-unused getRawValue() handle is dropped too.
- HotSwap::unpublishClassEntry() and Core::shutdown() duplicated the same
  logic; ClassDelta already used the helper, so this just finishes the job.

Semantics are unchanged: the destructor is still disabled for the delete, so
the payload (a shared zend_function, a rehomed class entry, a function embedded
in an immortalized closure) survives the bucket removal. Core::shutdown() keeps
its find() pre-check - zend_hash_del() reports an absent key as FAILURE, which
HashTable::delete() turns into an exception.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
HashTable::find() returns null for an absent key, and addInterfaces() called
getRawClass() on the result unchecked. interface_exists() is not a sufficient
pre-check: it accepts a leading-backslash name ("\Countable") that strtolower()
never turns into a class-table key, so the lookup misses and the method dies
with a call-on-null instead of a ReflectionException - halfway through, with a
freshly allocated interface buffer already tracked.

Adds the same guard every sibling lookup carries (setParent(), ObjectEntry,
ClosureEntry, ...), which also retires a baseline entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
initLowLevelStructures() cached a HashTable wrapper over zobj->properties once,
but setDynamicPropertiesPointer() rewrites that very field without touching the
shadow copy. The only reader, __debugInfo(), therefore dumped a table the object
no longer owns - possibly one already freed, i.e. a dangling read from a debug
dump. Objects whose table was built after the entry (the common lazy case) were
also missing from the dump entirely.

The field is removed and the table resolved from the live pointer at dump time,
so ObjectEntry keeps exactly one source of truth for zobj->properties.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
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.

2 participants