Skip to content

Commit 1ca77e9

Browse files
authored
Merge pull request #150 from WyriHaximus-secret-labs/port-memory-improvements-from-2.x
Port memory improvements from 2.x to master
2 parents f2c6529 + 587a098 commit 1ca77e9

File tree

3 files changed

+302
-26
lines changed

3 files changed

+302
-26
lines changed

src/Promise.php

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@ final class Promise implements PromiseInterface
1414
public function __construct(callable $resolver, callable $canceller = null)
1515
{
1616
$this->canceller = $canceller;
17-
$this->call($resolver);
17+
18+
// Explicitly overwrite arguments with null values before invoking
19+
// resolver function. This ensure that these arguments do not show up
20+
// in the stack trace in PHP 7+ only.
21+
$cb = $resolver;
22+
$resolver = $canceller = null;
23+
$this->call($cb);
1824
}
1925

2026
public function then(callable $onFulfilled = null, callable $onRejected = null): PromiseInterface
@@ -27,15 +33,26 @@ public function then(callable $onFulfilled = null, callable $onRejected = null):
2733
return new static($this->resolver($onFulfilled, $onRejected));
2834
}
2935

30-
$this->requiredCancelRequests++;
36+
// This promise has a canceller, so we create a new child promise which
37+
// has a canceller that invokes the parent canceller if all other
38+
// followers are also cancelled. We keep a reference to this promise
39+
// instance for the static canceller function and clear this to avoid
40+
// keeping a cyclic reference between parent and follower.
41+
$parent = $this;
42+
++$parent->requiredCancelRequests;
43+
44+
return new static(
45+
$this->resolver($onFulfilled, $onRejected),
46+
static function () use (&$parent) {
47+
--$parent->requiredCancelRequests;
3148

32-
return new static($this->resolver($onFulfilled, $onRejected), function () {
33-
$this->requiredCancelRequests--;
49+
if ($parent->requiredCancelRequests <= 0) {
50+
$parent->cancel();
51+
}
3452

35-
if ($this->requiredCancelRequests <= 0) {
36-
$this->cancel();
53+
$parent = null;
3754
}
38-
});
55+
);
3956
}
4057

4158
public function done(callable $onFulfilled = null, callable $onRejected = null): void
@@ -45,15 +62,15 @@ public function done(callable $onFulfilled = null, callable $onRejected = null):
4562
return;
4663
}
4764

48-
$this->handlers[] = function (PromiseInterface $promise) use ($onFulfilled, $onRejected) {
65+
$this->handlers[] = static function (PromiseInterface $promise) use ($onFulfilled, $onRejected) {
4966
$promise
5067
->done($onFulfilled, $onRejected);
5168
};
5269
}
5370

5471
public function otherwise(callable $onRejected): PromiseInterface
5572
{
56-
return $this->then(null, function ($reason) use ($onRejected) {
73+
return $this->then(null, static function ($reason) use ($onRejected) {
5774
if (!_checkTypehint($onRejected, $reason)) {
5875
return new RejectedPromise($reason);
5976
}
@@ -64,11 +81,11 @@ public function otherwise(callable $onRejected): PromiseInterface
6481

6582
public function always(callable $onFulfilledOrRejected): PromiseInterface
6683
{
67-
return $this->then(function ($value) use ($onFulfilledOrRejected) {
84+
return $this->then(static function ($value) use ($onFulfilledOrRejected) {
6885
return resolve($onFulfilledOrRejected())->then(function () use ($value) {
6986
return $value;
7087
});
71-
}, function ($reason) use ($onFulfilledOrRejected) {
88+
}, static function ($reason) use ($onFulfilledOrRejected) {
7289
return resolve($onFulfilledOrRejected())->then(function () use ($reason) {
7390
return new RejectedPromise($reason);
7491
});
@@ -113,23 +130,14 @@ public function cancel(): void
113130
private function resolver(callable $onFulfilled = null, callable $onRejected = null): callable
114131
{
115132
return function ($resolve, $reject) use ($onFulfilled, $onRejected) {
116-
$this->handlers[] = function (PromiseInterface $promise) use ($onFulfilled, $onRejected, $resolve, $reject) {
133+
$this->handlers[] = static function (PromiseInterface $promise) use ($onFulfilled, $onRejected, $resolve, $reject) {
117134
$promise
118135
->then($onFulfilled, $onRejected)
119136
->done($resolve, $reject);
120137
};
121138
};
122139
}
123140

124-
private function resolve($value = null): void
125-
{
126-
if (null !== $this->result) {
127-
return;
128-
}
129-
130-
$this->settle(resolve($value));
131-
}
132-
133141
private function reject(\Throwable $reason): void
134142
{
135143
if (null !== $this->result) {
@@ -175,8 +183,13 @@ private function unwrap($promise): PromiseInterface
175183
return $promise;
176184
}
177185

178-
private function call(callable $callback): void
186+
private function call(callable $cb): void
179187
{
188+
// Explicitly overwrite argument with null value. This ensure that this
189+
// argument does not show up in the stack trace in PHP 7+ only.
190+
$callback = $cb;
191+
$cb = null;
192+
180193
// Use reflection to inspect number of arguments expected by this callback.
181194
// We did some careful benchmarking here: Using reflection to avoid unneeded
182195
// function arguments is actually faster than blindly passing them.
@@ -195,16 +208,33 @@ private function call(callable $callback): void
195208
if ($args === 0) {
196209
$callback();
197210
} else {
211+
// Keep references to this promise instance for the static resolve/reject functions.
212+
// By using static callbacks that are not bound to this instance
213+
// and passing the target promise instance by reference, we can
214+
// still execute its resolving logic and still clear this
215+
// reference when settling the promise. This helps avoiding
216+
// garbage cycles if any callback creates an Exception.
217+
// These assumptions are covered by the test suite, so if you ever feel like
218+
// refactoring this, go ahead, any alternative suggestions are welcome!
219+
$target =& $this;
220+
198221
$callback(
199-
function ($value = null) {
200-
$this->resolve($value);
222+
static function ($value = null) use (&$target) {
223+
if ($target !== null) {
224+
$target->settle(resolve($value));
225+
$target = null;
226+
}
201227
},
202-
function (\Throwable $reason) {
203-
$this->reject($reason);
228+
static function (\Throwable $reason) use (&$target) {
229+
if ($target !== null) {
230+
$target->reject($reason);
231+
$target = null;
232+
}
204233
}
205234
);
206235
}
207236
} catch (\Throwable $e) {
237+
$target = null;
208238
$this->reject($e);
209239
}
210240
}

tests/DeferredTest.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,41 @@ public function getPromiseTestAdapter(callable $canceller = null)
1919
'settle' => [$d, 'resolve'],
2020
]);
2121
}
22+
23+
/** @test */
24+
public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerRejectsWithException()
25+
{
26+
gc_collect_cycles();
27+
$deferred = new Deferred(function ($resolve, $reject) {
28+
$reject(new \Exception('foo'));
29+
});
30+
$deferred->promise()->cancel();
31+
unset($deferred);
32+
33+
$this->assertSame(0, gc_collect_cycles());
34+
}
35+
36+
/** @test */
37+
public function shouldRejectWithoutCreatingGarbageCyclesIfParentCancellerRejectsWithException()
38+
{
39+
gc_collect_cycles();
40+
$deferred = new Deferred(function ($resolve, $reject) {
41+
$reject(new \Exception('foo'));
42+
});
43+
$deferred->promise()->then()->cancel();
44+
unset($deferred);
45+
46+
$this->assertSame(0, gc_collect_cycles());
47+
}
48+
49+
/** @test */
50+
public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerHoldsReferenceAndExplicitlyRejectWithException()
51+
{
52+
gc_collect_cycles();
53+
$deferred = new Deferred(function () use (&$deferred) { });
54+
$deferred->reject(new \Exception('foo'));
55+
unset($deferred);
56+
57+
$this->assertSame(0, gc_collect_cycles());
58+
}
2259
}

0 commit comments

Comments
 (0)