feat(type-system): enforce typed object constraints after unset operations

- Add runtime check mechanism for objects that require value validation
- Prevent null assignment to typed objects after unset with proper error message
- Disable native call optimizations when object values become unpredictable
- Maintain class constraints even after unset and reassignment
- Generate appropriate error handling for invalid object operations
- Update SSA analysis to recognize unset operations as dangerous for property hoisting
- Add comprehensive tests for typed object unset behavior scenarios
pull/47/head
韩天峰 2 weeks ago
parent a4f1188a7e
commit 70927ee68a
  1. 22
      phpunit/code/native-property-unset-disables-hoist.php
  2. 12
      phpunit/code/typed-object-unset-assign-null.php
  3. 8
      phpunit/src/AssignTest.php
  4. 13
      phpunit/src/NativePropertyTest.php
  5. 6
      phpunit/src/SsaAnalysisTest.php
  6. 10
      src/Context/FunctionContext.php
  7. 5
      src/Generator/TypeCheckGenerator.php
  8. 11
      src/Optimizer/SsaPropOptimizer.php
  9. 6
      src/Parser/AssignOpTrait.php
  10. 4
      src/Parser/MethodCallTrait.php
  11. 13
      src/Parser/PropertyAccessTrait.php
  12. 8
      src/Parser/TypeDetectionTrait.php
  13. 3
      src/Translator.php
  14. 4
      src/TypeSystem/NativeTypeCompatibilityTrait.php
  15. 58
      tests/compiler/basic/unset-typed-object-reassign.phpt
  16. 58
      tests/compiler/basic/unset-typed-object-state.phpt
  17. 2
      tests/compiler/optimizations/objprop-unset-this-typed.phpt

@ -0,0 +1,22 @@
<?php
use native_types;
class NativePropertyUnsetDisablesHoist
{
public int $value = 7;
public function run(): void
{
var_dump($this->value);
unset($this->value);
var_dump($this->value);
$this->value = 11;
var_dump($this->value);
}
}
function main(): void
{
(new NativePropertyUnsetDisablesHoist())->run();
}

@ -0,0 +1,12 @@
<?php
class TypedObjectUnsetNullValue
{
}
function main(): void
{
$value = new TypedObjectUnsetNullValue();
unset($value);
$value = null;
}

@ -22,6 +22,14 @@ class AssignTest extends \BaseTest
);
}
public function testCannotAssignNullToTypedObjectAfterUnset()
{
$this->exec(
'Cannot assign null to typed object `$value` of type `TypedObjectUnsetNullValue`; use unset() to clear it',
'typed-object-unset-assign-null.php'
);
}
public function testCannotAssignUnrelatedObjectToInterfaceDeclaredObject()
{
$this->exec(

@ -100,6 +100,19 @@ class NativePropertyTest extends \BaseTest
$this->assertStringContainsString('_object_prop_this___value = php::toIntExact(dynamicValue, "NativePropertyThisWriteConversionBox::$value");', $code);
}
public function testUnsetTypedPropertyDisablesSlotHoisting(): void
{
try {
$outputFile = $this->compileNativeProperty('native-property-unset-disables-hoist.php');
} catch (TestError $e) {
$this->fail($e->getMessage());
}
$code = file_get_contents($outputFile);
$this->assertStringNotContainsString('_object_prop_this___value', $code);
$this->assertStringContainsString('this_.attr(', $code);
}
public function testNativePropertyStaticScalarTypeMismatchFailsAtCompileTime(): void
{
$this->exec(

@ -655,7 +655,7 @@ class SsaAnalysisTest extends TestCase
));
$result = $this->invoke('hasDangerousPropOps', 'obj', [$unset, $read]);
$this->assertFalse($result, 'unset($obj->prop) is blocked by the object handlers and cannot invalidate a hoisted reference');
$this->assertTrue($result, 'unset($obj->prop) must disable property slot hoisting');
}
public function testHasDangerousPropOpsUnsetDifferentObj(): void
@ -933,7 +933,7 @@ class SsaAnalysisTest extends TestCase
$this->assertSame([], $result);
}
public function testCollectDangerousPropOpsUnsetAfterLastAccessIsSafe(): void
public function testCollectDangerousPropOpsUnsetAfterLastAccessIsAlwaysUnsafe(): void
{
$read = new Stmt\Expression(new Expr\Assign(
new Expr\Variable('value'),
@ -942,7 +942,7 @@ class SsaAnalysisTest extends TestCase
$unset = new Stmt\Unset_([new Expr\PropertyFetch(new Expr\Variable('obj'), 'a')]);
$result = $this->invoke('collectDangerousPropOps', 'obj', [$read, $unset]);
$this->assertSame([], $result);
$this->assertSame(['a' => true], $result);
}
public function testCollectDangerousPropOpsAssignRefToPropertyIsAlwaysUnsafe(): void

@ -29,6 +29,15 @@ class FunctionContext
*/
public array $objects = [];
/**
* Object variables that require a runtime value check before use. This
* includes nullable declarations and typed objects cleared by unset().
* Their class constraint remains in objects/declaredObjects.
*
* @var array<string, bool>
*/
public array $runtimeCheckedObjects = [];
/**
* Declared object constraints that are not used for native-call dispatch.
*
@ -86,6 +95,7 @@ class FunctionContext
$this->staticVars = [];
$this->arguments = [];
$this->objects = [];
$this->runtimeCheckedObjects = [];
$this->declaredObjects = [];
$this->stdArrays = [];
$this->stdContainers = [];

@ -273,6 +273,11 @@ trait TypeCheckGenerator
&& !$this->compositeTypeContainsKind($typeCheck, 'isInt');
}
protected function typeCheckAllowsNull(array $typeCheck): bool
{
return $this->compositeTypeContainsKind($typeCheck, 'isNull');
}
private function compositeTypeContainsKind(array $typeCheck, string $kind): bool
{
foreach ($typeCheck as $entry) {

@ -19,8 +19,8 @@
* 8. First access is not inside a loop or nested block scope
* 9. Property is not readonly (including properties of a readonly class)
*
* Direct unset($o->prop) is not dangerous: the object handlers reset/reject the
* unset path, so a hoisted reference is not invalidated by direct unset alone.
* Direct unset($o->prop) invalidates the property slot assumption. Any property
* observed in an unset operation must use dynamic attr() access.
*/
namespace TypePhp\Optimizer;
@ -361,8 +361,8 @@ trait SsaPropOptimizer
* - mutate($o) or $o->method() — dynamic code may turn the property slot
* into a reference through the exposed object
*
* Direct unset($o->prop) is intentionally not treated as dangerous: the
* object handlers reset/reject property unset.
* Direct unset($o->prop) invalidates the property slot and disables
* hoisting for that property.
*/
protected function hasDangerousPropOps(string $objName, array $stmts): bool
{
@ -408,10 +408,9 @@ trait SsaPropOptimizer
if ($node instanceof Node\Stmt\Unset_) {
foreach ($node->vars as $var) {
// unset($o->prop) cannot destroy the slot: the object handlers
// reject property unset, so a hoisted reference stays valid.
$propName = $this->getPropNameOfObj($var, $objName);
if ($propName !== null) {
$events[] = ['kind' => 'danger_always', 'prop' => $propName];
$this->collectPropEventsInDynamicParts($var, $objName, $events);
} else {
$this->collectPropEvents($var, $objName, $events);

@ -236,6 +236,12 @@ trait AssignOpTrait
if ($var === 'this_') {
$this->fatalError($left, 'Cannot re-assign $this');
}
if ($this->hasVar($var)
&& $this->getVarType($var) === Type::OBJECT
&& $this->isNull($right)) {
$class = $this->getDeclaredObjectType($var) ?: 'object';
$this->fatalError($right, "Cannot assign null to typed object `\${$var}` of type `{$class}`; use unset() to clear it");
}
if ($this->isStdContainer($var)) {
$copyAssign = $this->parseStdContainerCopyAssign($var, $right);
if ($copyAssign !== null) {

@ -120,6 +120,10 @@ trait MethodCallTrait
protected function findNativeMethod(CallLike $expr, string $object, string $method): string|false
{
if ($object !== 'this_' && $this->requiresRuntimeObjectCheck($object)) {
return false;
}
$classDef = null;
if ($object === 'this_') {
$class = $this->getFullClassName();

@ -765,6 +765,19 @@ trait PropertyAccessTrait
$type = $this->getVarType($name);
if ($this->isNativeType($type)) {
$this->warning($var, "Variable of native type `\${$name}` cannot be unset");
} elseif ($type === Type::OBJECT) {
// A PHP local read after unset() evaluates to null (and may
// emit an undefined-variable warning). Keep the Object
// wrapper so later object assignments remain valid, but
// store NULL rather than IS_UNDEF so strict null checks
// retain PHP value semantics.
//
// Keep the declared class: unset() changes only the value
// state and does not make null or another class assignable.
// Native operations must validate the current value before
// using the retained class declaration.
$this->context->runtimeCheckedObjects[$name] = true;
$lines[] = "{$name} = php::null;";
} else {
$lines[] = "{$name}.unset();";
}

@ -19,7 +19,13 @@ trait TypeDetectionTrait
{
public function isTypedObject(string $object): bool
{
return isset($this->context->objects[$object]) || isset($this->context->stableObjects[$object]);
return !isset($this->context->runtimeCheckedObjects[$object])
&& (isset($this->context->objects[$object]) || isset($this->context->stableObjects[$object]));
}
protected function requiresRuntimeObjectCheck(string $object): bool
{
return isset($this->context->runtimeCheckedObjects[$object]);
}
protected function isSuperGlobal(string $var): bool

@ -3603,6 +3603,9 @@ CODE;
$this->addArgument($argInfo->name, $argInfo->variadic ? Type::ARRAY : $argInfo->type);
if (!$argInfo->variadic and $argInfo->declaredClass) {
$this->addObject($argInfo->name, $argInfo->declaredClass);
if ($argInfo->nullable || $this->typeCheckAllowsNull($argInfo->typeCheck ?? [])) {
$this->context->runtimeCheckedObjects[$argInfo->name] = true;
}
}
}

@ -223,6 +223,10 @@ trait NativeTypeCompatibilityTrait
// 如果无法证明,但右值是已知 concrete object,说明一定不兼容,直接编译期 fatal;
// 其他动态/外部库/any 场景保留 php::toObject() 作为运行时兜底。
if ($this->isObjectClassStaticallyAssignableTo($class, $declaredClass)) {
if ($this->isVarExpr($arg->value)
&& $this->requiresRuntimeObjectCheck($this->parseIdentifier($arg->value))) {
return $this->convertObjectExpr($expr, $this->getClassEntryPtr($declaredClass));
}
return $type === Type::OBJECT ? $expr : $this->convertObjectExpr($expr);
}
if ($this->isKnownConcreteObjectExpr($arg->value, $class)) {

@ -0,0 +1,58 @@
--TEST--
unset typed object retains its declared class constraint on reassignment
--FILE--
<?php
class UnsetReassignExpected
{
public function value(): string
{
return 'expected';
}
}
class UnsetReassignOther
{
}
function makeUnsetReassignExpected(): UnsetReassignExpected
{
return new UnsetReassignExpected();
}
function makeUnsetReassignOtherDynamic(): mixed
{
return new UnsetReassignOther();
}
function makeUnsetReassignNullDynamic(): mixed
{
return null;
}
function main()
{
$value = makeUnsetReassignExpected();
unset($value);
try {
$value = makeUnsetReassignOtherDynamic();
echo "invalid assignment accepted\n";
} catch (Throwable $error) {
echo $error::class, "\n";
}
try {
$value = makeUnsetReassignNullDynamic();
echo "null assignment accepted\n";
} catch (Throwable $error) {
echo $error::class, "\n";
}
$value = makeUnsetReassignExpected();
var_dump($value->value());
}
?>
--EXPECT--
TypeError
TypeError
string(8) "expected"

@ -0,0 +1,58 @@
--TEST--
unset typed object invalidates native-call assumptions and reads as null
--FILE--
<?php
class UnsetTypedObjectValue
{
public function value(): string
{
return 'value';
}
}
function makeUnsetTypedObjectValue(): UnsetTypedObjectValue
{
return new UnsetTypedObjectValue();
}
function acceptUnsetTypedObjectValue(UnsetTypedObjectValue $value): string
{
echo "entered\n";
return $value->value();
}
function main()
{
$value = makeUnsetTypedObjectValue();
unset($value);
var_dump(@$value === null);
var_dump(@$value instanceof UnsetTypedObjectValue);
var_dump(isset($value));
try {
acceptUnsetTypedObjectValue(@$value);
} catch (Throwable $error) {
echo $error::class, "\n";
}
try {
@$value->value();
} catch (Throwable $error) {
echo $error::class, "\n";
}
$value = makeUnsetTypedObjectValue();
var_dump(acceptUnsetTypedObjectValue($value));
var_dump($value->value());
}
?>
--EXPECT--
bool(true)
bool(false)
bool(false)
TypeError
Error
entered
string(5) "value"
string(5) "value"

@ -1,5 +1,5 @@
--TEST--
SSA object prop: unset typed this property keeps AOT native slot semantics
SSA object prop: unset typed this property disables property slot hoisting
--FILE--
<?php
use native_types;

Loading…
Cancel
Save