Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ README.md export-ignore
CONTRIBUTING.md export-ignore
CHANGELOG.md export-ignore
psalm.xml export-ignore
stubs export-ignore
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ jobs:

- operating-system: 'ubuntu-latest'
php-version: '8.2'
psalm: 'skip'

- operating-system: 'ubuntu-latest'
php-version: '8.3'
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"require-dev": {
"amphp/php-cs-fixer-config": "^2",
"phpunit/phpunit": "^9",
"psalm/phar": "6.16.1"
"psalm/phar": "7.0.0-rc10"
},
"autoload": {
"psr-4": {
Expand Down
60 changes: 3 additions & 57 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,61 +11,7 @@
<directory name="src" />
</projectFiles>

<issueHandlers>
<ClassMustBeFinal>
<errorLevel type="suppress">
<file name="src/CancelledException.php"/>
<file name="src/SignalException.php"/>
<file name="src/TimeoutException.php"/>
</errorLevel>
</ClassMustBeFinal>

<DocblockTypeContradiction>
<errorLevel type="suppress">
<directory name="src" />
</errorLevel>
</DocblockTypeContradiction>

<MissingClosureReturnType>
<errorLevel type="suppress">
<directory name="src" />
</errorLevel>
</MissingClosureReturnType>

<PossiblyUnusedMethod>
<errorLevel type="suppress">
<directory name="src"/>
</errorLevel>
</PossiblyUnusedMethod>

<RedundantConditionGivenDocblockType>
<errorLevel type="suppress">
<directory name="src" />
</errorLevel>
</RedundantConditionGivenDocblockType>

<StringIncrement>
<errorLevel type="suppress">
<directory name="src" />
</errorLevel>
</StringIncrement>

<UnusedClass>
<errorLevel type="suppress">
<directory name="src"/>
</errorLevel>
</UnusedClass>

<UnusedVariable>
<errorLevel type="suppress">
<file name="src/functions.php"/>
</errorLevel>
</UnusedVariable>

<UnsupportedPropertyReferenceUsage>
<errorLevel type="suppress">
<directory name="src" />
</errorLevel>
</UnsupportedPropertyReferenceUsage>
</issueHandlers>
<stubs>
<file name="stubs/RevoltEventLoop.phpstub" />
</stubs>
</psalm>
17 changes: 17 additions & 0 deletions src/Cancellation.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

/**
* Cancellations are simple objects that allow registering handlers to subscribe to cancellation requests.
*
* What {@see subscribe()} and {@see unsubscribe()} may do depends on the implementation. Callers that accept any
* cancellation inherit that implementation's capabilities.
*
* @psalm-purity-template read-props <= C(read-props|write-this-props|write-props|write-refs|io) <= read-props|write-this-props|write-props|write-refs|io
*
* @api
*/
interface Cancellation
{
Expand All @@ -17,25 +24,35 @@ interface Cancellation
* `CancelledException` as first argument that may be used to fail the operation.
*
* @return string Identifier that can be used to cancel the subscription.
*
* @psalm-pure
* @psalm-purity-from-template C
*/
public function subscribe(\Closure $callback): string;

/**
* Unsubscribes a previously registered handler.
*
* The handler will no longer be called as long as this method isn't invoked from a subscribed callback.
*
* @psalm-pure
* @psalm-purity-from-template C
*/
public function unsubscribe(string $id): void;

/**
* Returns whether cancellation has been requested yet.
*
* @psalm-capabilities read-props
*/
public function isRequested(): bool;

/**
* Throws the `CancelledException` if cancellation has been requested, otherwise does nothing.
*
* @throws CancelledException
*
* @psalm-capabilities read-props|write-this-props|write-props|write-refs
*/
public function throwIfRequested(): void;
}
3 changes: 3 additions & 0 deletions src/CancelledException.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
*
* @see Cancellation
* @see DeferredCancellation
*
* @api
*/
class CancelledException extends \Exception
{
/** @psalm-capabilities read-props */
public function __construct(?\Throwable $previous = null)
{
parent::__construct("The operation was cancelled", 0, $previous);
Expand Down
16 changes: 16 additions & 0 deletions src/Closable.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,41 @@

namespace Amp;

/**
* A resource that can be closed.
*
* This package does not implement the interface. Closing a resource may suspend the event loop and mutate state, so
* the methods are impure and implementors are not restricted further.
*
* @psalm-mutable
*
* @api
*/
interface Closable
{
/**
* Closes the resource, marking it as unusable.
* Whether pending operations are aborted or not is implementation dependent.
*
* @psalm-impure
*/
public function close(): void;

/**
* Returns whether this resource has been closed.
*
* @return bool `true` if closed, otherwise `false`.
*
* @psalm-impure
*/
public function isClosed(): bool;

/**
* Registers a callback that is invoked when this resource is closed.
*
* @param \Closure():void $onClose
*
* @psalm-impure
*/
public function onClose(\Closure $onClose): void;
}
85 changes: 44 additions & 41 deletions src/CompositeCancellation.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,99 +2,101 @@

namespace Amp;

use Amp\Internal\CompositeCancellationState;
use Revolt\EventLoop;

/**
* A cancellation that is requested when any of the given cancellations is requested.
*
* @implements Cancellation[read-props|write-props|io]
*
* @api
*/
final class CompositeCancellation implements Cancellation
{
use ForbidCloning;
use ForbidSerialization;

/** @var array<int, array{Cancellation, string}> */
private array $cancellations = [];

private string $nextId = "a";

/** @var array<string, \Closure(CancelledException): void> */
private array $callbacks = [];

private ?CancelledException $exception = null;
private readonly CompositeCancellationState $state;

public function __construct(Cancellation ...$cancellations)
{
$thatException = &$this->exception;
$thatCallbacks = &$this->callbacks;
$thatCancellations = &$this->cancellations;
$onCancel = static function (CancelledException $exception) use (
&$thatException,
&$thatCallbacks,
&$thatCancellations,
): void {
if ($thatException) {
// The callback keeps this state object, not the cancellation, so destroying the cancellation can
// unsubscribe and break the cycle between the state and the child cancellations.
$state = new CompositeCancellationState();
$this->state = $state;

$onCancel = static function (CancelledException $exception) use ($state): void {
if ($state->exception) {
return;
}

$thatException = $exception;
$state->exception = $exception;

foreach ($thatCancellations as [$cancellation, $id]) {
foreach ($state->cancellations as [$cancellation, $id]) {
$cancellation->unsubscribe($id);
}

$thatCancellations = [];
$state->cancellations = [];

foreach ($thatCallbacks as $callback) {
foreach ($state->callbacks as $callback) {
EventLoop::queue($callback, $exception);
}

$thatCallbacks = [];
$state->callbacks = [];
};

foreach ($cancellations as $cancellation) {
$id = $cancellation->subscribe($onCancel);
$this->cancellations[] = [$cancellation, $id];
$state->cancellations[] = [$cancellation, $id];
}
}

public function __destruct()
{
foreach ($this->cancellations as [$cancellation, $id]) {
foreach ($this->state->cancellations as [$cancellation, $id]) {
$cancellation->unsubscribe($id);
}

// The reference created in the constructor causes this property to persist beyond the life of this object,
// so explicitly removing references will speed up garbage collection.
$this->cancellations = [];
$this->state->cancellations = [];
}

/**
* @psalm-capabilities read-props|write-props|io
*/
#[\Override]
public function subscribe(\Closure $callback): string
{
$id = $this->nextId;
\PHP_VERSION_ID >= 80300 ? $this->nextId = \str_increment($this->nextId) : ++$this->nextId;
$id = 'a' . $this->state->nextId++;

if ($this->exception) {
EventLoop::queue($callback, $this->exception);
if ($this->state->exception) {
EventLoop::queue($callback, $this->state->exception);
} else {
$this->callbacks[$id] = $callback;
$this->state->callbacks[$id] = $callback;
}

return $id;
}

/**
* @psalm-capabilities read-props|write-props
*/
#[\Override]
public function unsubscribe(string $id): void
{
unset($this->callbacks[$id]);
unset($this->state->callbacks[$id]);
}

/** @psalm-capabilities read-props */
#[\Override]
public function isRequested(): bool
{
if ($this->exception) {
if ($this->state->exception) {
return true;
}

// Check each cancellation directly, as the callback setting $this->exception is invoked asynchronously.
foreach ($this->cancellations as [$cancellation]) {
// Check each cancellation directly, as the callback setting the exception is invoked asynchronously.
foreach ($this->state->cancellations as [$cancellation]) {
if ($cancellation->isRequested()) {
return true;
}
Expand All @@ -103,15 +105,16 @@ public function isRequested(): bool
return false;
}

/** @psalm-capabilities read-props|write-props */
#[\Override]
public function throwIfRequested(): void
{
if ($this->exception) {
throw $this->exception;
if ($this->state->exception) {
throw $this->state->exception;
}

// Check each cancellation directly, as the callback setting $this->exception is invoked asynchronously.
foreach ($this->cancellations as [$cancellation]) {
// Check each cancellation directly, as the callback setting the exception is invoked asynchronously.
foreach ($this->state->cancellations as [$cancellation]) {
$cancellation->throwIfRequested();
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/CompositeException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

namespace Amp;

/**
* Thrown when multiple operations fail and each failure should be retained.
*
* @api
*/
final class CompositeException extends \Exception
{
/**
Expand All @@ -14,6 +19,8 @@ final class CompositeException extends \Exception
* @param string|null $message Exception message, defaults to message generated from passed exceptions.
*
* @psalm-assert non-empty-array<array-key, \Throwable> $reasons
*
* @psalm-capabilities read-props
*/
public function __construct(array $reasons, ?string $message = null)
{
Expand All @@ -32,6 +39,8 @@ public function getReasons(): array

/**
* @param non-empty-array<array-key, \Throwable> $reasons
*
* @psalm-pure
*/
private function generateMessage(array $reasons): string
{
Expand Down
6 changes: 6 additions & 0 deletions src/CompositeLengthException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

namespace Amp;

/**
* Thrown when a future combinator is given fewer futures than it requires.
*
* @api
*/
final class CompositeLengthException extends \Exception
{
/** @psalm-capabilities read-props */
public function __construct(string $message)
{
parent::__construct($message);
Expand Down
Loading
Loading