Skip to content

Commit 0d410ed

Browse files
committed
Add whoops error handler
1 parent 9850e2b commit 0d410ed

4 files changed

Lines changed: 254 additions & 26 deletions

File tree

src/WeakClosure.php

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
namespace Boson\Component\WeakType;
66

77
/**
8-
* A wrapper class that maintains a weak reference to the bound object of a closure
8+
* A wrapper class that maintains a weak reference to the bound object
9+
* of a closure.
910
*
1011
* This class allows callbacks to be executed even if their originally bound
1112
* object has been garbage collected. When the object is alive, the callback
1213
* executes with the object as context. When the object is dead, it falls back
1314
* to executing the callback without object context (statically), but within
14-
* the original class scope
15+
* the original class scope.
1516
*
1617
* ```
1718
* $closure = WeakClosure::create(function() {
@@ -27,61 +28,108 @@
2728
final readonly class WeakClosure
2829
{
2930
/**
30-
* Weak reference to the bound object of the original closure
31+
* The class name of the bound object.
3132
*
32-
* @var \WeakReference<TThis>
33+
* Used for static callback execution when the object is garbage collected.
34+
*
35+
* @var class-string<TThis>
3336
*/
34-
private \WeakReference $reference;
37+
private string $class;
3538

3639
/**
37-
* The class name of the bound object
38-
*
39-
* Used for static callback execution when the object is garbage collected
40+
* Weak reference to the bound object of the original closure.
4041
*
41-
* @var class-string<TThis>
42+
* @var \WeakReference<TThis>
4243
*/
43-
private string $class;
44+
private \WeakReference $object;
4445

4546
/**
46-
* The original closure wrapped by this {@see WeakClosure} instance
47+
* The original closure wrapped by this {@see WeakClosure} instance.
4748
*/
4849
private \Closure $callback;
4950

51+
/**
52+
* Indicates whether the closure is an internal PHP function.
53+
*/
54+
private bool $isInternal;
55+
5056
/**
5157
* @param TThis $reference The object bound to the original closure
5258
*
5359
* @internal Use the {@see WeakClosure::create()} instead
5460
*/
55-
private function __construct(object $reference, \Closure $callback)
61+
private function __construct(
62+
object $reference,
63+
\Closure $callback,
64+
\ReflectionFunction $reflection,
65+
) {
66+
$this->object = \WeakReference::create($reference);
67+
$this->callback = self::unbind($this->object, $callback, $reflection);
68+
$this->class = self::getClass($reference, $reflection);
69+
$this->isInternal = $reflection->isInternal();
70+
}
71+
72+
/**
73+
* @param TThis $reference
74+
*
75+
* @return class-string
76+
*/
77+
private static function getClass(object $reference, \ReflectionFunction $reflection): string
5678
{
57-
$this->reference = \WeakReference::create($reference);
58-
$this->callback = $callback->bindTo($this);
59-
$this->class = $reference::class;
79+
$class = $reflection->getClosureScopeClass();
80+
81+
if ($class === null) {
82+
return $reference::class;
83+
}
84+
85+
return $class->name;
86+
}
87+
88+
/**
89+
* Unbind `$this` context from a passed closure and creates a new
90+
* closure that references the target object weakly.
91+
*/
92+
private static function unbind(object $target, \Closure $callback, \ReflectionFunction $reflection): \Closure
93+
{
94+
if ($reflection->isAnonymous()) {
95+
return $callback->bindTo($target);
96+
}
97+
98+
$method = $reflection->getShortName();
99+
100+
return function (mixed ...$args) use ($method): mixed {
101+
return $this->{$method}(...$args);
102+
};
60103
}
61104

62105
/**
63-
* Creates a {@see WeakClosure} from a callable, or returns the callable
64-
* unchanged if it is not bound to an object
106+
* Creates a weak closure from the passed.
107+
*
108+
* If the closure is not bound to an object, returns it unchanged.
109+
*
110+
* Otherwise, wraps it in a {@see WeakClosure} that maintains a weak
111+
* reference to the bound object
65112
*
66113
* @template TArgClosure of \Closure
67114
*
68115
* @param TArgClosure $callback The callable to potentially wrap
69116
*
70-
* @return TArgClosure Returns a {@see \Closure} instance with weak
71-
* reference to `$this`
117+
* @return TArgClosure|\Closure Returns a {@see \Closure}
118+
* instance with weak reference to `$this`
72119
*
73120
* @noinspection PhpDocMissingThrowsInspection An exception never throws
74121
*/
75122
public static function create(\Closure $callback): \Closure
76123
{
77-
$reference = new \ReflectionFunction($callback)
78-
->getClosureThis();
124+
$reflection = new \ReflectionFunction($callback);
79125

80-
if ($reference === null) {
126+
$context = $reflection->getClosureThis();
127+
128+
if ($context === null) {
81129
return $callback;
82130
}
83131

84-
return (new self($reference, $callback))(...);
132+
return (new self($context, $callback, $reflection))(...);
85133
}
86134

87135
/**
@@ -94,15 +142,27 @@ public static function create(\Closure $callback): \Closure
94142
* @param mixed ...$args Arguments to pass to the callback
95143
*
96144
* @return mixed The result of the callback execution
145+
* @throws \RuntimeException If the GC has freed the bound object
97146
*/
98147
public function __invoke(mixed ...$args): mixed
99148
{
100-
$self = $this->reference->get();
149+
$self = $this->object->get();
101150

102151
if ($self === null) {
103-
$callback = $this->callback->bindTo(null, $this->class);
152+
$shortClassName = $this->class;
153+
154+
if (\is_int($shortClassNameOffset = \strpos($shortClassName, "\0"))) {
155+
$shortClassName = \substr($shortClassName, 0, $shortClassNameOffset);
156+
}
157+
158+
throw new \RuntimeException(\sprintf(
159+
'Cannot call a closure, instance of %s has already been removed by the GC',
160+
$shortClassName,
161+
));
162+
}
104163

105-
return $callback(...$args);
164+
if ($this->isInternal) {
165+
return $this->callback->bindTo($self)(...$args);
106166
}
107167

108168
return $this->callback->call($self, ...$args);

tests/WeakClosureTest.php

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Boson\Component\WeakType\Tests\WeakClosureTest\ParentStub;
6+
use Boson\Component\WeakType\Tests\WeakClosureTest\StaticStub;
7+
use Boson\Component\WeakType\WeakClosure;
8+
use PHPUnit\Framework\TestCase;
9+
10+
final class WeakClosureTest extends TestCase
11+
{
12+
public function testCreateReturnsSameInstance(): void
13+
{
14+
$closure = WeakClosure::create(static function () {
15+
return 123;
16+
});
17+
18+
self::assertSame($closure, WeakClosure::create($closure));
19+
}
20+
21+
public function testInstanceMethodWhileObjectAlive(): void
22+
{
23+
$object = new class {
24+
public function foo(): int
25+
{
26+
return 42;
27+
}
28+
};
29+
30+
$closure = WeakClosure::create($object->foo(...));
31+
32+
self::assertSame(42, $closure());
33+
}
34+
35+
public function testInstanceMethodAfterGarbageCollectionThrows(): void
36+
{
37+
$closure = (function () {
38+
$object = new class {
39+
public function foo(): int
40+
{
41+
return 42;
42+
}
43+
};
44+
45+
return WeakClosure::create($object->foo(...));
46+
})();
47+
48+
gc_collect_cycles();
49+
50+
$this->expectException(RuntimeException::class);
51+
$closure();
52+
}
53+
54+
public function testProtectedMethodScopeIsPreserved(): void
55+
{
56+
$object = new class {
57+
protected function foo(): int
58+
{
59+
return 123;
60+
}
61+
62+
public function get(): Closure
63+
{
64+
return $this->foo(...);
65+
}
66+
};
67+
68+
$closure = WeakClosure::create($object->get());
69+
70+
self::assertSame(123, $closure());
71+
}
72+
73+
public function testInheritedMethodAfterGarbageCollection(): void
74+
{
75+
$closure = (function () {
76+
$object = new class extends ParentStub {
77+
public function get(): Closure
78+
{
79+
return $this->value(...);
80+
}
81+
};
82+
83+
return WeakClosure::create($object->get());
84+
})();
85+
86+
gc_collect_cycles();
87+
88+
$this->expectException(RuntimeException::class);
89+
$closure();
90+
}
91+
92+
public function testStaticMethodIsCallableAfterGc(): void
93+
{
94+
$closure = WeakClosure::create(StaticStub::value(...));
95+
96+
gc_collect_cycles();
97+
98+
self::assertSame(10, $closure());
99+
}
100+
101+
public function testPureClosureIsNotWeakReferenced(): void
102+
{
103+
$closure = WeakClosure::create(fn () => 123);
104+
105+
gc_collect_cycles();
106+
107+
self::assertSame(123, $closure());
108+
}
109+
110+
public function testRepeatedInvocationAfterGcAlwaysFails(): void
111+
{
112+
$closure = (function () {
113+
$object = new class {
114+
public function foo(): int { return 1; }
115+
};
116+
return WeakClosure::create($object->foo(...));
117+
})();
118+
119+
gc_collect_cycles();
120+
121+
try {
122+
$closure();
123+
} catch (RuntimeException) {}
124+
125+
$this->expectException(RuntimeException::class);
126+
$closure();
127+
}
128+
129+
public function testInvokeMethod(): void
130+
{
131+
$object = new class {
132+
public function __invoke(): int
133+
{
134+
return 7;
135+
}
136+
};
137+
138+
$closure = WeakClosure::create($object(...));
139+
140+
self::assertSame(7, $closure());
141+
}
142+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Boson\Component\WeakType\Tests\WeakClosureTest;
6+
7+
abstract class ParentStub
8+
{
9+
protected function value(): int
10+
{
11+
return 5;
12+
}
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Boson\Component\WeakType\Tests\WeakClosureTest;
6+
7+
final class StaticStub
8+
{
9+
public static function value(): int
10+
{
11+
return 10;
12+
}
13+
}

0 commit comments

Comments
 (0)