Skip to content

Commit ba3d99b

Browse files
committed
Defer object destructors to the next VM safepoint
Run an object's __destruct at the next VM safepoint instead of inline at the point its refcount reaches zero, so a destructor can no longer reenter the VM in the middle of an opcode's operand cleanup. Logical death stays synchronous: a WeakReference reads null and a WeakMap entry is gone at the drop, and only the physical destructor call and the free are deferred. Safepoints are entry into a user function, method, closure, magic method or hook, a statement-level internal call, every try-region boundary (entry, normal exit, return/break/continue/goto/throw out of a try, and the end of a finally body), a loop back-edge, and script or fiber end. Because a flushed destructor exception is thrown from an opline still inside the try region that owns it, the existing exception table delivers it to the same catch that would have caught an inline throw; region scoping is positional and needs no per-region runtime state. Several destructor exceptions at one safepoint compose into a single getPrevious chain, an exit or unwind exception wins over a destructor exception, and every object is destroyed exactly once. Behavior is identical across the interpreter, tracing JIT, and function JIT. Generators and fibers carry their own deferred-destructor queue, so a destructor deferred inside one runs there rather than in the consumer. Based on the destructor deferral in arnaud-lb#31, which first delayed destructors and user error handlers to the vm_interrupt safepoint. The flush here additionally fires at every try-region boundary, so a deferred destructor exception stays catchable by its enclosing try rather than escaping it. Fixes GH-20001
1 parent c7265d4 commit ba3d99b

102 files changed

Lines changed: 3287 additions & 886 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

UPGRADING

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ PHP 8.6 UPGRADE NOTES
2323
. It is no longer possible to clone variant objects, this is because
2424
the cloning behaviour was ill defined.
2525

26+
- Core:
27+
. Object destructors (__destruct) now run at the next VM safepoint (a
28+
function call, loop back-edge, try-region boundary, or script end) rather
29+
than synchronously when an object's last reference is released. Logical
30+
death stays synchronous: a WeakReference reads null and a WeakMap entry is
31+
gone as soon as the reference drops. Code that assumed a destructor ran
32+
before the next statement in the same scope may observe its side effects
33+
slightly later, and a destructor's stack trace now shows [internal
34+
function] for the frame that triggered it instead of the releasing line.
35+
An exception thrown from a destructor is raised at the safepoint and
36+
remains catchable by the try/catch that was active when the object was
37+
released.
38+
2639
- DOM:
2740
. Properties previously documented as @readonly (e.g. DOMNode::$nodeType,
2841
DOMDocument::$xmlEncoding, DOMEntity::$actualEncoding, ::$encoding,

Zend/tests/bug38220.phpt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ A Object
6767
)
6868
func1(): 1
6969
after call func1
70+
before call func1
7071
A::__destruct()
7172
before call close
7273
A Object
@@ -75,7 +76,6 @@ A Object
7576
)
7677
close(): 1
7778
after call close
78-
before call func1
7979
A Object
8080
(
8181
[i] => 2
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
--TEST--
2+
Deferred destructor: a throwing destructor dropped in a try is caught by that try when the try is left via return/break/continue
3+
--FILE--
4+
<?php
5+
class D {
6+
public function __construct(public string $id) {}
7+
public function __destruct() { throw new Exception("dtor {$this->id}"); }
8+
}
9+
10+
function via_return() {
11+
try {
12+
$d = new D("r");
13+
unset($d);
14+
return "returned";
15+
} catch (Exception $e) {
16+
echo "return-case caught: {$e->getMessage()}\n";
17+
return "caught";
18+
}
19+
}
20+
var_dump(via_return());
21+
22+
function via_break() {
23+
foreach ([1] as $v) {
24+
try {
25+
$d = new D("b");
26+
unset($d);
27+
break;
28+
} catch (Exception $e) {
29+
echo "break-case caught: {$e->getMessage()}\n";
30+
}
31+
}
32+
return "done";
33+
}
34+
var_dump(via_break());
35+
36+
function via_continue() {
37+
foreach ([1, 2] as $v) {
38+
try {
39+
$d = new D("c$v");
40+
unset($d);
41+
continue;
42+
} catch (Exception $e) {
43+
echo "continue-case caught: {$e->getMessage()}\n";
44+
}
45+
}
46+
return "done";
47+
}
48+
var_dump(via_continue());
49+
?>
50+
--EXPECT--
51+
return-case caught: dtor r
52+
string(6) "caught"
53+
break-case caught: dtor b
54+
string(4) "done"
55+
continue-case caught: dtor c1
56+
continue-case caught: dtor c2
57+
string(4) "done"
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
--TEST--
2+
Deferred destructor: exit() in a destructor wins over a throwing destructor in the same flush, either order
3+
--FILE--
4+
<?php
5+
class Exiter {
6+
function __destruct() {
7+
echo "Exiter dtor\n";
8+
exit(7);
9+
}
10+
}
11+
class Thrower {
12+
public $id;
13+
function __construct(int $id) { $this->id = $id; }
14+
function __destruct() {
15+
echo "Thrower {$this->id} dtor\n";
16+
throw new Exception("thrown {$this->id}");
17+
}
18+
}
19+
20+
register_shutdown_function(function () { echo "shutdown\n"; });
21+
22+
// Thrower deferred before Exiter, then Exiter: exit must win over the pending throw.
23+
$t = new Thrower(1);
24+
$t = null;
25+
$e = new Exiter();
26+
$e = null;
27+
28+
// A safepoint flushes the batch. exit(7) wins; no user code runs afterwards.
29+
(function () {})();
30+
31+
echo "UNREACHABLE\n";
32+
?>
33+
--EXPECT--
34+
Thrower 1 dtor
35+
Exiter dtor
36+
shutdown
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
--TEST--
2+
Deferred destructor: a destructor dropped in a finally body composes with a destructor exception pending from the protected block and is caught by the enclosing try
3+
--FILE--
4+
<?php
5+
class D {
6+
public function __construct(public string $id) {}
7+
public function __destruct() { throw new Exception("dtor {$this->id}"); }
8+
}
9+
function f() {
10+
try {
11+
try {
12+
$a = new D("try");
13+
unset($a);
14+
return "unreached";
15+
} finally {
16+
$b = new D("finally");
17+
unset($b);
18+
echo "finally body ran\n";
19+
}
20+
} catch (Exception $e) {
21+
echo $e::class, ": ", $e->getMessage(), "\n";
22+
echo "previous: ", $e->getPrevious()?->getMessage() ?? "none", "\n";
23+
return "caught";
24+
}
25+
}
26+
var_dump(f());
27+
?>
28+
--EXPECT--
29+
finally body ran
30+
Exception: dtor finally
31+
previous: dtor try
32+
string(6) "caught"
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
--TEST--
2+
Deferred destructor: a throwing destructor deferred in a finally runs exactly once
3+
--FILE--
4+
<?php
5+
class D {
6+
function __destruct() {
7+
echo "dtor\n";
8+
throw new Exception("boom");
9+
}
10+
}
11+
function g() {
12+
try {
13+
return 1;
14+
} finally {
15+
$x = new D();
16+
unset($x);
17+
}
18+
}
19+
g();
20+
echo "after g\n";
21+
?>
22+
--EXPECTF--
23+
dtor
24+
25+
Fatal error: Uncaught Exception: boom in %s:%d
26+
Stack trace:
27+
#0 %s(%d): D->__destruct()
28+
#1 %s(%d): g()
29+
#2 {main}
30+
thrown in %s on line %d
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
--TEST--
2+
Deferred destructor: a drop inside a GC destructor of a condemned sibling runs inline
3+
--FILE--
4+
<?php
5+
$log = 0;
6+
class Node {
7+
public $next;
8+
public $peer;
9+
function __destruct() {
10+
global $log;
11+
$log++;
12+
$this->peer = null;
13+
}
14+
}
15+
16+
for ($r = 0; $r < 2000; $r++) {
17+
$a = new Node();
18+
$b = new Node();
19+
$c = new Node();
20+
$a->next = $b;
21+
$b->next = $c;
22+
$c->next = $a;
23+
$a->peer = $c;
24+
$b->peer = $a;
25+
$c->peer = $b;
26+
unset($a, $b, $c);
27+
if ($r % 500 === 0) {
28+
gc_collect_cycles();
29+
}
30+
}
31+
gc_collect_cycles();
32+
echo "log=$log\n";
33+
?>
34+
--EXPECT--
35+
log=6000
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
--TEST--
2+
Deferred destructors flush at loop back-edges
3+
--INI--
4+
opcache.enable_cli=0
5+
--FILE--
6+
<?php
7+
$dtors = 0;
8+
9+
class C {
10+
public function __destruct() {
11+
global $dtors;
12+
$dtors++;
13+
}
14+
}
15+
16+
for ($i = 0; $i < 3; $i++) {
17+
$object = new C;
18+
unset($object);
19+
echo $i, ":", $dtors, "\n";
20+
}
21+
22+
echo "done:", $dtors, "\n";
23+
?>
24+
--EXPECT--
25+
0:0
26+
1:1
27+
2:2
28+
done:2
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
--TEST--
2+
Deferred destructor: nested try regions scope the deferred queue; each region's destructor surfaces at its own level and a finally-return discards only its own
3+
--FILE--
4+
<?php
5+
class D {
6+
public function __construct(public string $id) {}
7+
public function __destruct() { throw new Exception("dtor {$this->id}"); }
8+
}
9+
10+
function inner_level() {
11+
try {
12+
try {
13+
$d = new D("inner");
14+
unset($d);
15+
return "inner-try-return";
16+
} catch (Exception $e) {
17+
echo "inner caught: {$e->getMessage()}\n";
18+
return "inner-catch";
19+
}
20+
} catch (Exception $e) {
21+
echo "outer WRONGLY caught: {$e->getMessage()}\n";
22+
return "outer-catch";
23+
}
24+
}
25+
var_dump(inner_level());
26+
27+
function outer_survives() {
28+
try {
29+
$d = new D("outer");
30+
unset($d);
31+
try {
32+
return "inner-try-return";
33+
} finally {
34+
return "inner-finally-return";
35+
}
36+
} catch (Exception $e) {
37+
echo "outer caught: {$e->getMessage()}\n";
38+
return "outer-catch";
39+
}
40+
}
41+
var_dump(outer_survives());
42+
43+
function finally_discards() {
44+
try {
45+
$d = new D("discarded");
46+
unset($d);
47+
return "try-return";
48+
} finally {
49+
return "finally-return";
50+
}
51+
}
52+
var_dump(finally_discards());
53+
?>
54+
--EXPECT--
55+
inner caught: dtor inner
56+
string(11) "inner-catch"
57+
outer caught: dtor outer
58+
string(11) "outer-catch"
59+
string(14) "finally-return"
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
--TEST--
2+
Deferred destructor: a throwing destructor pending at an explicit throw preempts it, whether dropped before the statement or during the throw operand's evaluation, so no spurious exception chain is created
3+
--FILE--
4+
<?php
5+
class D {
6+
public function __destruct() { throw new Exception("dtor"); }
7+
}
8+
9+
// The throw operand is a pre-existing object; the destructor is dropped before it.
10+
function rethrow_case() {
11+
try {
12+
try {
13+
throw new Exception("original");
14+
} catch (Exception $orig) {
15+
$x = new D();
16+
unset($x);
17+
throw $orig;
18+
}
19+
} catch (Exception $e) {
20+
echo "rethrow: ", $e->getMessage(), " prev=", $e->getPrevious()?->getMessage() ?? "none", "\n";
21+
}
22+
}
23+
rethrow_case();
24+
25+
// The throw operand is a call that drops the destructor during its evaluation.
26+
function make(Exception $e) {
27+
$x = new D();
28+
unset($x);
29+
return $e;
30+
}
31+
function operand_call_case() {
32+
$pre = new Exception("explicit");
33+
try {
34+
throw make($pre);
35+
} catch (Exception $e) {
36+
echo "operand-call: ", $e->getMessage(), " prev=", $e->getPrevious()?->getMessage() ?? "none", "\n";
37+
}
38+
}
39+
operand_call_case();
40+
?>
41+
--EXPECT--
42+
rethrow: dtor prev=none
43+
operand-call: dtor prev=none

0 commit comments

Comments
 (0)