Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Port memory improvements from 2.x to master #150

82 changes: 56 additions & 26 deletions src/Promise.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ final class Promise implements PromiseInterface
public function __construct(callable $resolver, callable $canceller = null)
{
$this->canceller = $canceller;
$this->call($resolver);

// Explicitly overwrite arguments with null values before invoking
// resolver function. This ensure that these arguments do not show up
// in the stack trace in PHP 7+ only.
$cb = $resolver;
$resolver = $canceller = null;
$this->call($cb);
}

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

$this->requiredCancelRequests++;
// This promise has a canceller, so we create a new child promise which
// has a canceller that invokes the parent canceller if all other
// followers are also cancelled. We keep a reference to this promise
// instance for the static canceller function and clear this to avoid
// keeping a cyclic reference between parent and follower.
$parent = $this;
++$parent->requiredCancelRequests;

return new static(
$this->resolver($onFulfilled, $onRejected),
static function () use (&$parent) {
--$parent->requiredCancelRequests;

return new static($this->resolver($onFulfilled, $onRejected), function () {
$this->requiredCancelRequests--;
if ($parent->requiredCancelRequests <= 0) {
$parent->cancel();
}

if ($this->requiredCancelRequests <= 0) {
$this->cancel();
$parent = null;
}
});
);
}

public function done(callable $onFulfilled = null, callable $onRejected = null): void
Expand All @@ -45,15 +62,15 @@ public function done(callable $onFulfilled = null, callable $onRejected = null):
return;
}

$this->handlers[] = function (PromiseInterface $promise) use ($onFulfilled, $onRejected) {
$this->handlers[] = static function (PromiseInterface $promise) use ($onFulfilled, $onRejected) {
$promise
->done($onFulfilled, $onRejected);
};
}

public function otherwise(callable $onRejected): PromiseInterface
{
return $this->then(null, function ($reason) use ($onRejected) {
return $this->then(null, static function ($reason) use ($onRejected) {
if (!_checkTypehint($onRejected, $reason)) {
return new RejectedPromise($reason);
}
Expand All @@ -64,11 +81,11 @@ public function otherwise(callable $onRejected): PromiseInterface

public function always(callable $onFulfilledOrRejected): PromiseInterface
{
return $this->then(function ($value) use ($onFulfilledOrRejected) {
return $this->then(static function ($value) use ($onFulfilledOrRejected) {
return resolve($onFulfilledOrRejected())->then(function () use ($value) {
return $value;
});
}, function ($reason) use ($onFulfilledOrRejected) {
}, static function ($reason) use ($onFulfilledOrRejected) {
return resolve($onFulfilledOrRejected())->then(function () use ($reason) {
return new RejectedPromise($reason);
});
Expand Down Expand Up @@ -113,23 +130,14 @@ public function cancel(): void
private function resolver(callable $onFulfilled = null, callable $onRejected = null): callable
{
return function ($resolve, $reject) use ($onFulfilled, $onRejected) {
$this->handlers[] = function (PromiseInterface $promise) use ($onFulfilled, $onRejected, $resolve, $reject) {
$this->handlers[] = static function (PromiseInterface $promise) use ($onFulfilled, $onRejected, $resolve, $reject) {
$promise
->then($onFulfilled, $onRejected)
->done($resolve, $reject);
};
};
}

private function resolve($value = null): void
{
if (null !== $this->result) {
return;
}

$this->settle(resolve($value));
}

private function reject(\Throwable $reason): void
{
if (null !== $this->result) {
Expand Down Expand Up @@ -175,8 +183,13 @@ private function unwrap($promise): PromiseInterface
return $promise;
}

private function call(callable $callback): void
private function call(callable $cb): void
{
// Explicitly overwrite argument with null value. This ensure that this
// argument does not show up in the stack trace in PHP 7+ only.
$callback = $cb;
$cb = null;

// Use reflection to inspect number of arguments expected by this callback.
// We did some careful benchmarking here: Using reflection to avoid unneeded
// function arguments is actually faster than blindly passing them.
Expand All @@ -195,16 +208,33 @@ private function call(callable $callback): void
if ($args === 0) {
$callback();
} else {
// Keep references to this promise instance for the static resolve/reject functions.
// By using static callbacks that are not bound to this instance
// and passing the target promise instance by reference, we can
// still execute its resolving logic and still clear this
// reference when settling the promise. This helps avoiding
// garbage cycles if any callback creates an Exception.
// These assumptions are covered by the test suite, so if you ever feel like
// refactoring this, go ahead, any alternative suggestions are welcome!
$target =& $this;

$callback(
function ($value = null) {
$this->resolve($value);
static function ($value = null) use (&$target) {
if ($target !== null) {
$target->settle(resolve($value));
$target = null;
}
},
function (\Throwable $reason) {
$this->reject($reason);
static function (\Throwable $reason) use (&$target) {
if ($target !== null) {
$target->reject($reason);
$target = null;
}
}
);
}
} catch (\Throwable $e) {
$target = null;
$this->reject($e);
}
}
Expand Down
37 changes: 37 additions & 0 deletions tests/DeferredTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,41 @@ public function getPromiseTestAdapter(callable $canceller = null)
'settle' => [$d, 'resolve'],
]);
}

/** @test */
public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerRejectsWithException()
{
gc_collect_cycles();
$deferred = new Deferred(function ($resolve, $reject) {
$reject(new \Exception('foo'));
});
$deferred->promise()->cancel();
unset($deferred);

$this->assertSame(0, gc_collect_cycles());
}

/** @test */
public function shouldRejectWithoutCreatingGarbageCyclesIfParentCancellerRejectsWithException()
{
gc_collect_cycles();
$deferred = new Deferred(function ($resolve, $reject) {
$reject(new \Exception('foo'));
});
$deferred->promise()->then()->cancel();
unset($deferred);

$this->assertSame(0, gc_collect_cycles());
}

/** @test */
public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerHoldsReferenceAndExplicitlyRejectWithException()
{
gc_collect_cycles();
$deferred = new Deferred(function () use (&$deferred) { });
$deferred->reject(new \Exception('foo'));
unset($deferred);

$this->assertSame(0, gc_collect_cycles());
}
}
Loading