Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
14 / 14
CRAP
100.00% covered (success)
100.00%
1 / 1
OperationDeadline
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
14 / 14
28
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 begin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 remaining
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasEnded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 throwIfCallerRequested
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 checkpoint
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 outcome
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 arm
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 close
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 subscribe
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 unsubscribe
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRequested
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 throwIfRequested
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 timeout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * SPDX-License-Identifier: EUPL-1.2
5 *
6 * This file is part of icap-flow.
7 *
8 * Licensed under the EUPL, Version 1.2 only (the "Licence");
9 * you may not use this work except in compliance with the Licence.
10 * You may obtain a copy of the Licence at:
11 *
12 *     https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
13 *
14 * Unless required by applicable law or agreed to in writing, software
15 * distributed under the Licence is distributed on an "AS IS" basis,
16 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 */
18
19declare(strict_types=1);
20
21namespace Ndrstmr\Icap;
22
23use Amp\Cancellation;
24use Amp\CancelledException;
25use Amp\DeferredCancellation;
26use Closure;
27use Ndrstmr\Icap\Exception\IcapTimeoutException;
28use Revolt\EventLoop;
29
30/**
31 * The end of one operation (M10): a point on the monotonic clock, fixed when a public method is called, that every
32 * phase, attempt and pause stays inside. It travels as the {@see Cancellation} of the call, so that code that knows
33 * nothing about it (a connector, a producer, another client) ends with the caller's cancellation and with the end of
34 * the operation alike, and code that does can ask how much time is left.
35 *
36 * {@see begin()} takes the caller's own cancellation: if that already is an operation, the new end is the earlier of
37 * the two, so a nested call never extends what its caller has. Separate and parallel calls begin their own.
38 *
39 * The end is looked at, not only waited for: a timer cannot fire while something holds the event loop, so
40 * {@see throwIfRequested()}, {@see isRequested()} and {@see checkpoint()} compare the clock, and the places that get
41 * control back call them. The caller's cancellation (also a `TimeoutCancellation` of theirs) stays an
42 * `Amp\CancelledException` and wins when both apply; the end of the operation is an {@see IcapTimeoutException}
43 * ({@see checkpoint()} throws it, {@see outcome()} turns the `CancelledException` the end caused into it). The timer
44 * and the subscription to the caller are set by the first one who waits for the end ({@see subscribe()}) and removed
45 * by {@see close()} in a `finally`, not by destructors: an exception that is still referenced keeps the arguments of
46 * its stack frames alive.
47 *
48 * @internal
49 */
50final class OperationDeadline implements Cancellation
51{
52    /** More than any operation takes; it keeps the arithmetic of a huge but finite limit finite. */
53    private const float LONGEST_SECONDS = 1.0e9;
54
55    private ?DeferredCancellation $source = null;
56
57    private ?string $timer = null;
58
59    private ?string $callerWatch = null;
60
61    private bool $closed = false;
62
63    private function __construct(
64        private readonly ?Cancellation $caller,
65        private readonly float $endsAt,
66    ) {
67    }
68
69    /**
70     * An operation of at most $seconds from now, or until the end of the operation $caller belongs to if that is earlier.
71     */
72    public static function begin(?Cancellation $caller, float $seconds): self
73    {
74        $endsAt = hrtime(true) + min($seconds, self::LONGEST_SECONDS) * 1e9;
75
76        return new self($caller, $caller instanceof self ? min($endsAt, $caller->endsAt) : $endsAt);
77    }
78
79    /**
80     * Seconds left; 0 once the end has passed.
81     */
82    public function remaining(): float
83    {
84        return max(0.0, ($this->endsAt - hrtime(true)) / 1e9);
85    }
86
87    /**
88     * Whether the end has passed, by the clock; the timer runs only when the event loop does.
89     */
90    public function hasEnded(): bool
91    {
92        return hrtime(true) >= $this->endsAt;
93    }
94
95    /**
96     * Throws what the caller's own cancellation throws, if it has been requested - not the end of the operation,
97     * also not that of an operation the caller belongs to, which is a timeout of this one as well.
98     *
99     * @throws CancelledException
100     */
101    public function throwIfCallerRequested(): void
102    {
103        if ($this->caller instanceof self) {
104            $this->caller->throwIfCallerRequested();
105
106            return;
107        }
108
109        $this->caller?->throwIfRequested();
110    }
111
112    /**
113     * For the places that get control back from something that does not look by itself: the caller's cancellation
114     * as the exception it is, the end of the operation as an {@see IcapTimeoutException}.
115     *
116     * @param \Throwable|null $cause what the operation was doing when it ran out (kept as the previous exception)
117     *
118     * @throws CancelledException   When the caller has cancelled
119     * @throws IcapTimeoutException When the end of the operation has passed
120     */
121    public function checkpoint(?\Throwable $cause = null): void
122    {
123        $this->throwIfCallerRequested();
124
125        if ($this->hasEnded()) {
126            throw self::timeout($cause);
127        }
128    }
129
130    /**
131     * The exception to throw for one that left the operation: the caller's cancellation stays what it is, the
132     * `CancelledException` that the end caused becomes an {@see IcapTimeoutException}, everything else (a
133     * cancellation nobody here caused included) is returned as it is.
134     *
135     * @param \Throwable|null $cause what the operation was doing when it ran out (kept as the previous exception)
136     */
137    public function outcome(\Throwable $e, ?\Throwable $cause = null): \Throwable
138    {
139        if (!$e instanceof CancelledException) {
140            return $e;
141        }
142
143        try {
144            $this->throwIfCallerRequested();
145        } catch (CancelledException $callerCancelled) {
146            return $callerCancelled;
147        }
148
149        return $this->hasEnded() ? self::timeout($cause) : $e;
150    }
151
152    /**
153     * Sets the timer and the subscription to the caller, once, for whoever waits for the end; after {@see close()}
154     * no more. Without a waiter there is nothing to wake, and the clock alone says whether the end has passed.
155     */
156    private function arm(): void
157    {
158        if ($this->source !== null || $this->closed) {
159            return;
160        }
161
162        $source = new DeferredCancellation();
163        $this->source = $source;
164
165        $this->timer = EventLoop::delay($this->remaining(), static function () use ($source): void {
166            $source->cancel(self::timeout());
167        });
168        $this->callerWatch = $this->caller?->subscribe(static function (CancelledException $e) use ($source): void {
169            $source->cancel($e->getPrevious());
170        });
171    }
172
173    /**
174     * Removes the timer and the subscription. The operation is over; the object stays usable as a plain check.
175     */
176    public function close(): void
177    {
178        if ($this->closed) {
179            return;
180        }
181        $this->closed = true;
182
183        if ($this->timer !== null) {
184            EventLoop::cancel($this->timer);
185            $this->timer = null;
186        }
187        if ($this->caller !== null && $this->callerWatch !== null) {
188            $this->caller->unsubscribe($this->callerWatch);
189            $this->callerWatch = null;
190        }
191    }
192
193    #[\Override]
194    public function subscribe(Closure $callback): string
195    {
196        $this->arm();
197        // An operation that is closed and was never armed has nothing left that could fire.
198        $this->source ??= new DeferredCancellation();
199
200        return $this->source->getCancellation()->subscribe($callback);
201    }
202
203    #[\Override]
204    public function unsubscribe(string $id): void
205    {
206        $this->source?->getCancellation()->unsubscribe($id);
207    }
208
209    #[\Override]
210    public function isRequested(): bool
211    {
212        return ($this->caller?->isRequested() ?? false) || $this->hasEnded();
213    }
214
215    #[\Override]
216    public function throwIfRequested(): void
217    {
218        $this->throwIfCallerRequested();
219
220        if ($this->hasEnded()) {
221            throw new CancelledException(self::timeout());
222        }
223    }
224
225    /**
226     * The exception for an operation whose time is up.
227     */
228    public static function timeout(?\Throwable $cause = null): IcapTimeoutException
229    {
230        return new IcapTimeoutException('The operation did not finish within its operation timeout.', 0, $cause);
231    }
232}