doeff-time 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- doeff_time/__init__.py +34 -0
- doeff_time/_internals/__init__.py +10 -0
- doeff_time/_internals/deadline.py +14 -0
- doeff_time/_internals/sim_clock.py +24 -0
- doeff_time/_internals/time_queue.py +68 -0
- doeff_time/_internals/validation.py +12 -0
- doeff_time/effects/__init__.py +29 -0
- doeff_time/effects/time.py +261 -0
- doeff_time/epoch_ms.py +21 -0
- doeff_time/handlers/__init__.py +13 -0
- doeff_time/handlers/_wall_ticks.py +59 -0
- doeff_time/handlers/async_time.py +142 -0
- doeff_time/handlers/sim_time.py +328 -0
- doeff_time/handlers/sync_time.py +127 -0
- doeff_time/py.typed +1 -0
- doeff_time-0.1.0.dist-info/METADATA +58 -0
- doeff_time-0.1.0.dist-info/RECORD +18 -0
- doeff_time-0.1.0.dist-info/WHEEL +4 -0
doeff_time/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Public API for doeff-time."""
|
|
2
|
+
|
|
3
|
+
from doeff_time._internals.sim_clock import SimClock as SimClock
|
|
4
|
+
from doeff_time.effects.time import Delay as Delay
|
|
5
|
+
from doeff_time.effects.time import DelayEffect as DelayEffect
|
|
6
|
+
from doeff_time.effects.time import GetMonotonic as GetMonotonic
|
|
7
|
+
from doeff_time.effects.time import GetMonotonicEffect as GetMonotonicEffect
|
|
8
|
+
from doeff_time.effects.time import GetTime as GetTime
|
|
9
|
+
from doeff_time.effects.time import GetTimeEffect as GetTimeEffect
|
|
10
|
+
from doeff_time.effects.time import RaceOutcome as RaceOutcome
|
|
11
|
+
from doeff_time.effects.time import RaceWithin as RaceWithin
|
|
12
|
+
from doeff_time.effects.time import RaceWithinEffect as RaceWithinEffect
|
|
13
|
+
from doeff_time.effects.time import ScheduleAt as ScheduleAt
|
|
14
|
+
from doeff_time.effects.time import ScheduleAtEffect as ScheduleAtEffect
|
|
15
|
+
from doeff_time.effects.time import SetTime as SetTime
|
|
16
|
+
from doeff_time.effects.time import SetTimeEffect as SetTimeEffect
|
|
17
|
+
from doeff_time.effects.time import TicksOutcome as TicksOutcome
|
|
18
|
+
from doeff_time.effects.time import WaitTicks as WaitTicks
|
|
19
|
+
from doeff_time.effects.time import WaitTicksEffect as WaitTicksEffect
|
|
20
|
+
from doeff_time.effects.time import WaitUntil as WaitUntil
|
|
21
|
+
from doeff_time.effects.time import WaitUntilEffect as WaitUntilEffect
|
|
22
|
+
from doeff_time.effects.time import WaitWithin as WaitWithin
|
|
23
|
+
from doeff_time.effects.time import WaitWithinEffect as WaitWithinEffect
|
|
24
|
+
from doeff_time.effects.time import delay as delay
|
|
25
|
+
from doeff_time.effects.time import get_monotonic as get_monotonic
|
|
26
|
+
from doeff_time.effects.time import get_time as get_time
|
|
27
|
+
from doeff_time.effects.time import schedule_at as schedule_at
|
|
28
|
+
from doeff_time.effects.time import set_time as set_time
|
|
29
|
+
from doeff_time.effects.time import wait_until as wait_until
|
|
30
|
+
from doeff_time.effects.time import wait_within as wait_within
|
|
31
|
+
from doeff_time.epoch_ms import epoch_ms_of as epoch_ms_of
|
|
32
|
+
from doeff_time.handlers.async_time import async_time_handler as async_time_handler
|
|
33
|
+
from doeff_time.handlers.sim_time import sim_time_handler as sim_time_handler
|
|
34
|
+
from doeff_time.handlers.sync_time import sync_time_handler as sync_time_handler
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The value a timed wait's deadline completes with."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class _DeadlinePassed:
|
|
5
|
+
"""The deadline of a timed wait passed. Told apart from every future's value by identity (a future may
|
|
6
|
+
complete with None — RaceWithin), so the clock handlers complete their deadline with this, never None."""
|
|
7
|
+
|
|
8
|
+
__slots__ = ()
|
|
9
|
+
|
|
10
|
+
def __repr__(self) -> str:
|
|
11
|
+
return "DEADLINE_PASSED"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DEADLINE_PASSED = _DeadlinePassed()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Virtual monotonic clock for simulation handlers."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from .validation import ensure_aware_datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class SimClock:
|
|
12
|
+
current_time: datetime = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
13
|
+
|
|
14
|
+
def __post_init__(self) -> None:
|
|
15
|
+
self.current_time = ensure_aware_datetime(self.current_time, name="current_time")
|
|
16
|
+
|
|
17
|
+
def advance_to(self, target_time: datetime) -> datetime:
|
|
18
|
+
target = ensure_aware_datetime(target_time, name="target_time")
|
|
19
|
+
self.current_time = max(self.current_time, target)
|
|
20
|
+
return self.current_time
|
|
21
|
+
|
|
22
|
+
def set_time(self, new_time: datetime) -> datetime:
|
|
23
|
+
self.current_time = ensure_aware_datetime(new_time, name="new_time")
|
|
24
|
+
return self.current_time
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Min-heap queue of time-ordered promises."""
|
|
2
|
+
|
|
3
|
+
import heapq
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from doeff_core_effects.scheduler import Promise
|
|
9
|
+
|
|
10
|
+
from doeff_time._internals.validation import ensure_aware_datetime
|
|
11
|
+
|
|
12
|
+
# ``Promise`` is generic to the type checker but not subscriptable at runtime.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class TimeQueueEntry:
|
|
17
|
+
time: datetime
|
|
18
|
+
sequence: int
|
|
19
|
+
promise: "Promise[Any]"
|
|
20
|
+
# The value the clock completes ``promise`` with (a RaceWithin deadline's DEADLINE_PASSED, else None).
|
|
21
|
+
answer: Any = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TimeQueue:
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._mut_sequence = 0
|
|
27
|
+
self._mut_items: "list[tuple[datetime, int, Promise[Any], Any]]" = [] # noqa: UP037
|
|
28
|
+
# Sequences of entries withdrawn before their time (a timed wait whose future won —
|
|
29
|
+
# agora-redesign #2618). Dropped lazily when they reach the head, so withdrawing is O(1)
|
|
30
|
+
# and the clock never advances to a deadline nobody waits for.
|
|
31
|
+
self._mut_withdrawn: set[int] = set()
|
|
32
|
+
# Sequences still in the heap and not withdrawn — answers "is it still queued?" in O(1).
|
|
33
|
+
self._mut_queued: set[int] = set()
|
|
34
|
+
|
|
35
|
+
def push(self, time: datetime, promise: "Promise[Any]", answer: Any = None) -> int:
|
|
36
|
+
"""Queue ``promise`` to be completed with ``answer`` at ``time``; answers the entry's sequence (for ``withdraw``)."""
|
|
37
|
+
target_time = ensure_aware_datetime(time, name="time")
|
|
38
|
+
self._mut_sequence += 1
|
|
39
|
+
heapq.heappush(self._mut_items, (target_time, self._mut_sequence, promise, answer))
|
|
40
|
+
self._mut_queued.add(self._mut_sequence)
|
|
41
|
+
return self._mut_sequence
|
|
42
|
+
|
|
43
|
+
def withdraw(self, sequence: int) -> None:
|
|
44
|
+
"""Drop a queued entry before its time (no-op when it was already popped)."""
|
|
45
|
+
if sequence in self._mut_queued:
|
|
46
|
+
self._mut_queued.discard(sequence)
|
|
47
|
+
self._mut_withdrawn.add(sequence)
|
|
48
|
+
self._drop_withdrawn_head()
|
|
49
|
+
|
|
50
|
+
def _drop_withdrawn_head(self) -> None:
|
|
51
|
+
"""Keep the head a live entry, so ``pop`` / ``empty`` never see a withdrawn deadline."""
|
|
52
|
+
while self._mut_items and self._mut_items[0][1] in self._mut_withdrawn:
|
|
53
|
+
_, sequence, _, _ = heapq.heappop(self._mut_items)
|
|
54
|
+
self._mut_withdrawn.discard(sequence)
|
|
55
|
+
|
|
56
|
+
def pop(self) -> TimeQueueEntry:
|
|
57
|
+
self._drop_withdrawn_head()
|
|
58
|
+
time, sequence, promise, answer = heapq.heappop(self._mut_items)
|
|
59
|
+
self._mut_queued.discard(sequence)
|
|
60
|
+
self._drop_withdrawn_head()
|
|
61
|
+
return TimeQueueEntry(time=time, sequence=sequence, promise=promise, answer=answer)
|
|
62
|
+
|
|
63
|
+
def empty(self) -> bool:
|
|
64
|
+
self._drop_withdrawn_head()
|
|
65
|
+
return not self._mut_items
|
|
66
|
+
|
|
67
|
+
def __len__(self) -> int:
|
|
68
|
+
return len(self._mut_queued)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Internal validation helpers for doeff-time."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def ensure_aware_datetime(value: datetime, *, name: str) -> datetime:
|
|
8
|
+
if not isinstance(value, datetime):
|
|
9
|
+
raise TypeError(f"{name} must be datetime, got {type(value).__name__}")
|
|
10
|
+
if value.tzinfo is None:
|
|
11
|
+
raise ValueError(f"{name} must be timezone-aware datetime")
|
|
12
|
+
return value
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Public time effects for doeff-time."""
|
|
2
|
+
|
|
3
|
+
from doeff_time.effects.time import Delay as Delay
|
|
4
|
+
from doeff_time.effects.time import DelayEffect as DelayEffect
|
|
5
|
+
from doeff_time.effects.time import GetMonotonic as GetMonotonic
|
|
6
|
+
from doeff_time.effects.time import GetMonotonicEffect as GetMonotonicEffect
|
|
7
|
+
from doeff_time.effects.time import GetTime as GetTime
|
|
8
|
+
from doeff_time.effects.time import GetTimeEffect as GetTimeEffect
|
|
9
|
+
from doeff_time.effects.time import RaceOutcome as RaceOutcome
|
|
10
|
+
from doeff_time.effects.time import RaceWithin as RaceWithin
|
|
11
|
+
from doeff_time.effects.time import RaceWithinEffect as RaceWithinEffect
|
|
12
|
+
from doeff_time.effects.time import ScheduleAt as ScheduleAt
|
|
13
|
+
from doeff_time.effects.time import ScheduleAtEffect as ScheduleAtEffect
|
|
14
|
+
from doeff_time.effects.time import SetTime as SetTime
|
|
15
|
+
from doeff_time.effects.time import SetTimeEffect as SetTimeEffect
|
|
16
|
+
from doeff_time.effects.time import TicksOutcome as TicksOutcome
|
|
17
|
+
from doeff_time.effects.time import WaitTicks as WaitTicks
|
|
18
|
+
from doeff_time.effects.time import WaitTicksEffect as WaitTicksEffect
|
|
19
|
+
from doeff_time.effects.time import WaitUntil as WaitUntil
|
|
20
|
+
from doeff_time.effects.time import WaitUntilEffect as WaitUntilEffect
|
|
21
|
+
from doeff_time.effects.time import WaitWithin as WaitWithin
|
|
22
|
+
from doeff_time.effects.time import WaitWithinEffect as WaitWithinEffect
|
|
23
|
+
from doeff_time.effects.time import delay as delay
|
|
24
|
+
from doeff_time.effects.time import get_monotonic as get_monotonic
|
|
25
|
+
from doeff_time.effects.time import get_time as get_time
|
|
26
|
+
from doeff_time.effects.time import schedule_at as schedule_at
|
|
27
|
+
from doeff_time.effects.time import set_time as set_time
|
|
28
|
+
from doeff_time.effects.time import wait_until as wait_until
|
|
29
|
+
from doeff_time.effects.time import wait_within as wait_within
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Provider-agnostic time effects."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from doeff import EffectBase
|
|
10
|
+
from doeff_time._internals.validation import ensure_aware_datetime
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from doeff_core_effects.scheduler import Future, Task
|
|
14
|
+
|
|
15
|
+
_T = TypeVar("_T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _coerce_finite_float(value: float, *, name: str) -> float:
|
|
19
|
+
if not isinstance(value, (int, float)):
|
|
20
|
+
raise TypeError(f"{name} must be float, got {type(value).__name__}")
|
|
21
|
+
coerced = float(value)
|
|
22
|
+
if math.isnan(coerced) or math.isinf(coerced):
|
|
23
|
+
raise ValueError(f"{name} must be finite, got {value!r}")
|
|
24
|
+
return coerced
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class DelayEffect(EffectBase):
|
|
29
|
+
"""Sleep for a duration in seconds."""
|
|
30
|
+
|
|
31
|
+
seconds: float
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
seconds = _coerce_finite_float(self.seconds, name="seconds")
|
|
35
|
+
if seconds < 0.0:
|
|
36
|
+
raise ValueError("seconds must be >= 0.0")
|
|
37
|
+
object.__setattr__(self, "seconds", seconds)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class WaitUntilEffect(EffectBase):
|
|
42
|
+
"""Wait until a specific timezone-aware datetime."""
|
|
43
|
+
|
|
44
|
+
target: datetime
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
object.__setattr__(self, "target", ensure_aware_datetime(self.target, name="target"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class GetTimeEffect(EffectBase):
|
|
52
|
+
"""Read current timezone-aware datetime."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class GetMonotonicEffect(EffectBase):
|
|
57
|
+
"""Read monotonic seconds as a float.
|
|
58
|
+
|
|
59
|
+
Only the difference between two readings is meaningful (durations,
|
|
60
|
+
deadlines inside one process). Unlike ``GetTime`` it never jumps backwards
|
|
61
|
+
with wall-clock adjustments. The origin is handler-defined: wall-clock
|
|
62
|
+
handlers answer ``time.monotonic()``; ``sim_time_handler`` answers the
|
|
63
|
+
virtual clock's POSIX timestamp.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ScheduleAtEffect(EffectBase):
|
|
69
|
+
"""Schedule a program for execution at a specific timezone-aware datetime."""
|
|
70
|
+
|
|
71
|
+
time: datetime
|
|
72
|
+
program: Any
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
object.__setattr__(self, "time", ensure_aware_datetime(self.time, name="time"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class WaitWithinEffect(EffectBase, Generic[_T]):
|
|
80
|
+
"""Wait for a scheduler future for at most ``seconds``.
|
|
81
|
+
|
|
82
|
+
Answers the future's value, or ``None`` when ``seconds`` pass first — so the
|
|
83
|
+
future's producer must not complete it with ``None``. One effect replaces the
|
|
84
|
+
"spawn a sleeping timer task, Race it, cancel it" pattern: the clock handler
|
|
85
|
+
owns the deadline (``sim_time_handler`` puts it on its virtual time queue and
|
|
86
|
+
drops it when the future wins — no task is spawned), which keeps a timed wait
|
|
87
|
+
as cheap as a Delay (agora-redesign #2618).
|
|
88
|
+
|
|
89
|
+
``park`` is for an *external* promise that in-run code completes (doeff-records'
|
|
90
|
+
memory-store bell, rung by a synchronous write): the race against the deadline
|
|
91
|
+
parks like ``Wait(future, priority=PRIORITY_IDLE)`` instead of shielding the sim
|
|
92
|
+
clock, so virtual time can reach the deadline while the promise is pending. With
|
|
93
|
+
the default (``False``) a pending external promise holds the clock, and the
|
|
94
|
+
deadline cannot pass before it completes (agora-redesign #3054).
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
future: "Future[_T]"
|
|
98
|
+
seconds: float
|
|
99
|
+
park: bool = False
|
|
100
|
+
|
|
101
|
+
def __post_init__(self) -> None:
|
|
102
|
+
seconds = _coerce_finite_float(self.seconds, name="seconds")
|
|
103
|
+
if seconds < 0.0:
|
|
104
|
+
raise ValueError("seconds must be >= 0.0")
|
|
105
|
+
if not isinstance(self.park, bool):
|
|
106
|
+
raise TypeError(f"park must be bool, got {type(self.park).__name__}")
|
|
107
|
+
object.__setattr__(self, "seconds", seconds)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class RaceOutcome(Generic[_T]):
|
|
112
|
+
"""The answer of RaceWithin: ``value`` = the value of the first of the futures to complete (None when the
|
|
113
|
+
deadline passed first) and ``timed_out`` = whether the deadline passed first."""
|
|
114
|
+
|
|
115
|
+
value: "_T | None"
|
|
116
|
+
timed_out: bool
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class RaceWithinEffect(EffectBase, Generic[_T]):
|
|
121
|
+
"""Wait for the first of several scheduler futures or tasks for at most ``seconds``.
|
|
122
|
+
|
|
123
|
+
WaitWithin for a Race: answers ``RaceOutcome(value, False)`` with the value of the first of ``futures``
|
|
124
|
+
to complete (a failure or a cancellation is raised, as ``Race`` does), or ``RaceOutcome(None, True)`` when
|
|
125
|
+
``seconds`` pass first. A future may complete with ``None``: the deadline is told apart by ``timed_out``,
|
|
126
|
+
not by the value. One effect replaces "spawn a sleeping timer task, Race it against the futures, cancel
|
|
127
|
+
it": ``sim_time_handler`` puts the deadline on its virtual time queue and withdraws it when a future wins
|
|
128
|
+
— no task is spawned (doeff-cluster's worker tick pause races its wake bells against the next due time
|
|
129
|
+
this way). ``park`` is WaitWithin's.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
futures: "tuple[Task[_T] | Future[_T], ...]"
|
|
133
|
+
seconds: float
|
|
134
|
+
park: bool = False
|
|
135
|
+
|
|
136
|
+
def __post_init__(self) -> None:
|
|
137
|
+
if not isinstance(self.futures, tuple):
|
|
138
|
+
raise TypeError(f"futures must be a tuple, got {type(self.futures).__name__}")
|
|
139
|
+
if not self.futures:
|
|
140
|
+
raise ValueError("futures must hold at least one Task or Future (a bare deadline is a Delay)")
|
|
141
|
+
seconds = _coerce_finite_float(self.seconds, name="seconds")
|
|
142
|
+
if seconds < 0.0:
|
|
143
|
+
raise ValueError("seconds must be >= 0.0")
|
|
144
|
+
if not isinstance(self.park, bool):
|
|
145
|
+
raise TypeError(f"park must be bool, got {type(self.park).__name__}")
|
|
146
|
+
object.__setattr__(self, "seconds", seconds)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class TicksOutcome(Generic[_T]):
|
|
151
|
+
"""The answer of WaitTicks: ``value`` = the future's value (None when the ticks ran out first) and
|
|
152
|
+
``passed`` = how many ticks passed before the answer (0 to ``count``)."""
|
|
153
|
+
|
|
154
|
+
value: "_T | None"
|
|
155
|
+
passed: int
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class WaitTicksEffect(EffectBase, Generic[_T]):
|
|
160
|
+
"""Wait for a scheduler future across a run of ``count`` ticks spaced ``every`` seconds, without waking
|
|
161
|
+
at each tick.
|
|
162
|
+
|
|
163
|
+
The ticks are at ``every``, ``2 * every`` … ``count * every`` seconds from now. Answers
|
|
164
|
+
``TicksOutcome(value, passed)``: the future's value when it completes first (``passed`` = the ticks
|
|
165
|
+
that passed before it), or ``TicksOutcome(None, count)`` when the last tick passes first.
|
|
166
|
+
|
|
167
|
+
The order at a tick instant is that of a waiter that re-registers ``WaitWithin(future, every)`` at
|
|
168
|
+
each tick (agora-redesign #3066): tick k counts as passed before any other timer due at the same
|
|
169
|
+
instant that was registered after tick k-1 passed, and after one registered before it.
|
|
170
|
+
``sim_time_handler`` keeps that order by re-queueing tick k+1 on its virtual time queue the moment
|
|
171
|
+
tick k is taken off it — the waiting task is not woken at the ticks. ``park`` is WaitWithin's.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
future: "Future[_T]"
|
|
175
|
+
every: float
|
|
176
|
+
count: int
|
|
177
|
+
park: bool = False
|
|
178
|
+
|
|
179
|
+
def __post_init__(self) -> None:
|
|
180
|
+
every = _coerce_finite_float(self.every, name="every")
|
|
181
|
+
if every <= 0.0:
|
|
182
|
+
raise ValueError("every must be > 0.0")
|
|
183
|
+
if isinstance(self.count, bool) or not isinstance(self.count, int):
|
|
184
|
+
raise TypeError(f"count must be int, got {type(self.count).__name__}")
|
|
185
|
+
if self.count < 1:
|
|
186
|
+
raise ValueError("count must be >= 1")
|
|
187
|
+
if not isinstance(self.park, bool):
|
|
188
|
+
raise TypeError(f"park must be bool, got {type(self.park).__name__}")
|
|
189
|
+
object.__setattr__(self, "every", every)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class SetTimeEffect(EffectBase):
|
|
194
|
+
"""Set current timezone-aware datetime (simulation handlers may support this effect)."""
|
|
195
|
+
|
|
196
|
+
time: datetime
|
|
197
|
+
|
|
198
|
+
def __post_init__(self) -> None:
|
|
199
|
+
object.__setattr__(self, "time", ensure_aware_datetime(self.time, name="time"))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def delay(seconds: float) -> DelayEffect:
|
|
203
|
+
return DelayEffect(seconds=seconds)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def wait_until(target: datetime) -> WaitUntilEffect:
|
|
207
|
+
return WaitUntilEffect(target=target)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_time() -> GetTimeEffect:
|
|
211
|
+
return GetTimeEffect()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_monotonic() -> GetMonotonicEffect:
|
|
215
|
+
return GetMonotonicEffect()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def schedule_at(time: datetime, program: Any) -> ScheduleAtEffect:
|
|
219
|
+
return ScheduleAtEffect(time=time, program=program)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def set_time(time: datetime) -> SetTimeEffect:
|
|
223
|
+
return SetTimeEffect(time=time)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def wait_within(future: "Future[_T]", seconds: float, *, park: bool = False) -> "WaitWithinEffect[_T]":
|
|
227
|
+
return WaitWithinEffect(future=future, seconds=seconds, park=park)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def Delay(seconds: float) -> EffectBase: # noqa: N802
|
|
231
|
+
return DelayEffect(seconds=seconds)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def WaitUntil(target: datetime) -> EffectBase: # noqa: N802
|
|
235
|
+
return WaitUntilEffect(target=target)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def GetTime() -> EffectBase: # noqa: N802
|
|
239
|
+
return GetTimeEffect()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def GetMonotonic() -> EffectBase: # noqa: N802
|
|
243
|
+
return GetMonotonicEffect()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def ScheduleAt(time: datetime, program: Any) -> EffectBase: # noqa: N802
|
|
247
|
+
return ScheduleAtEffect(time=time, program=program)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def SetTime(time: datetime) -> EffectBase: # noqa: N802
|
|
251
|
+
return SetTimeEffect(time=time)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def WaitWithin(future: "Future[_T]", seconds: float, *, park: bool = False) -> EffectBase: # noqa: N802
|
|
255
|
+
return WaitWithinEffect(future=future, seconds=seconds, park=park)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# The effects' constructors under the names the other waits use (the classes themselves — a typed answer, no wrapper).
|
|
259
|
+
WaitTicks = WaitTicksEffect
|
|
260
|
+
RaceWithin = RaceWithinEffect
|
|
261
|
+
|
doeff_time/epoch_ms.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""時刻を epoch ミリ秒の整数へ換算する式の唯一の定義(GetTime の答えを、他の process や記録の刻と並べる物差しへ)。
|
|
2
|
+
|
|
3
|
+
同じ刻を 2 か所が別の丸め方(四捨五入と床)で綴ると、329.6 ms に書いた刻が 329.9 ms に刻んだ後の刻より 1 ms 後に見える
|
|
4
|
+
(#3855)。丸めはここの 1 つだけにし、刻を並べる所はすべてこれで綴る。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
from doeff_time._internals.validation import ensure_aware_datetime
|
|
10
|
+
|
|
11
|
+
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
12
|
+
_ONE_MS = timedelta(milliseconds=1)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def epoch_ms_of(at: datetime) -> int:
|
|
16
|
+
"""timezone つきの時刻 → epoch ミリ秒(床へ丸める)。
|
|
17
|
+
|
|
18
|
+
timedelta の整数の割り算で求め、float を経ない(float の timestamp を 1000 倍すると 1 ms ずれることがある)。起点より前の
|
|
19
|
+
時刻も床へ丸める(1969-12-31T23:59:59.9995Z → -1)。時差の付き方が違う 2 つの時刻も同じ物差しに乗る。
|
|
20
|
+
"""
|
|
21
|
+
return (ensure_aware_datetime(at, name="at") - _EPOCH) // _ONE_MS
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Public handlers for doeff-time."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from .async_time import ProtocolHandler, async_time_handler
|
|
5
|
+
from .sim_time import sim_time_handler
|
|
6
|
+
from .sync_time import sync_time_handler
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ProtocolHandler",
|
|
10
|
+
"async_time_handler",
|
|
11
|
+
"sim_time_handler",
|
|
12
|
+
"sync_time_handler",
|
|
13
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The three timed waits on a wall clock — WaitWithin, WaitTicks (agora-redesign #3066) and RaceWithin.
|
|
2
|
+
|
|
3
|
+
All race the futures against one deadline timer that completes with DEADLINE_PASSED; they differ only in
|
|
4
|
+
which futures, how long the deadline is and in the answer. The async and sync handlers share this, so their
|
|
5
|
+
one clause answers the three waits the same way.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from doeff_core_effects.scheduler import Future, Task
|
|
9
|
+
|
|
10
|
+
from doeff_time._internals.deadline import DEADLINE_PASSED
|
|
11
|
+
from doeff_time.effects import (
|
|
12
|
+
RaceOutcome,
|
|
13
|
+
RaceWithinEffect,
|
|
14
|
+
TicksOutcome,
|
|
15
|
+
WaitTicksEffect,
|
|
16
|
+
WaitWithinEffect,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
TimedWait = WaitWithinEffect[object] | WaitTicksEffect[object] | RaceWithinEffect[object]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def timed_wait_futures(effect: TimedWait) -> "tuple[Task[object] | Future[object], ...]":
|
|
23
|
+
"""What ``effect`` races against its deadline: RaceWithin's ``futures``, or the one ``future``."""
|
|
24
|
+
match effect:
|
|
25
|
+
case RaceWithinEffect():
|
|
26
|
+
return effect.futures
|
|
27
|
+
case WaitWithinEffect() | WaitTicksEffect():
|
|
28
|
+
return (effect.future,)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def timed_wait_seconds(effect: TimedWait) -> float:
|
|
32
|
+
"""How long the deadline of ``effect`` is on the wall clock: WaitWithin's and RaceWithin's ``seconds``, or
|
|
33
|
+
the time to WaitTicks' last tick."""
|
|
34
|
+
match effect:
|
|
35
|
+
case WaitWithinEffect() | RaceWithinEffect():
|
|
36
|
+
return max(0.0, effect.seconds)
|
|
37
|
+
case WaitTicksEffect():
|
|
38
|
+
return effect.every * effect.count
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def timed_wait_answer(effect: TimedWait, first: object, elapsed: float) -> object:
|
|
42
|
+
"""The answer of ``effect`` once the race gave ``first`` (DEADLINE_PASSED when the deadline won) after
|
|
43
|
+
``elapsed`` seconds: WaitWithin answers the future's value or None; RaceWithin answers it as a RaceOutcome;
|
|
44
|
+
WaitTicks answers it with the ticks that passed — a wall clock does not observe them one by one, so all of
|
|
45
|
+
them when the ticks ran out first, else the whole spacings in ``elapsed``, at most ``count``."""
|
|
46
|
+
timed_out = first is DEADLINE_PASSED
|
|
47
|
+
value = None if timed_out else first
|
|
48
|
+
match effect:
|
|
49
|
+
case WaitWithinEffect():
|
|
50
|
+
return value
|
|
51
|
+
case RaceWithinEffect():
|
|
52
|
+
return RaceOutcome(value=value, timed_out=timed_out)
|
|
53
|
+
case WaitTicksEffect():
|
|
54
|
+
passed = (
|
|
55
|
+
effect.count
|
|
56
|
+
if timed_out
|
|
57
|
+
else min(effect.count, max(0, int(elapsed // effect.every)))
|
|
58
|
+
)
|
|
59
|
+
return TicksOutcome(value=value, passed=passed)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Asyncio-backed wall-clock handler for doeff-time effects."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from doeff_core_effects import Await
|
|
11
|
+
from doeff_core_effects.scheduler import PRIORITY_IDLE, Cancel, Race, Spawn
|
|
12
|
+
|
|
13
|
+
from doeff import Pass, Transfer, do
|
|
14
|
+
from doeff import handler as _program_handler
|
|
15
|
+
from doeff.program import ProgramHandler
|
|
16
|
+
from doeff_time._internals.deadline import DEADLINE_PASSED
|
|
17
|
+
from doeff_time.effects import (
|
|
18
|
+
DelayEffect,
|
|
19
|
+
GetMonotonicEffect,
|
|
20
|
+
GetTimeEffect,
|
|
21
|
+
RaceWithinEffect,
|
|
22
|
+
ScheduleAtEffect,
|
|
23
|
+
WaitTicksEffect,
|
|
24
|
+
WaitUntilEffect,
|
|
25
|
+
WaitWithinEffect,
|
|
26
|
+
)
|
|
27
|
+
from doeff_time.handlers._wall_ticks import (
|
|
28
|
+
timed_wait_answer,
|
|
29
|
+
timed_wait_futures,
|
|
30
|
+
timed_wait_seconds,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
ProtocolHandler = Callable[[Any, Any], Any]
|
|
34
|
+
|
|
35
|
+
# The effects AsyncTimeRuntime.handle answers.
|
|
36
|
+
AsyncTimeEffect = (
|
|
37
|
+
DelayEffect
|
|
38
|
+
| WaitUntilEffect
|
|
39
|
+
| GetTimeEffect
|
|
40
|
+
| GetMonotonicEffect
|
|
41
|
+
| WaitWithinEffect
|
|
42
|
+
| WaitTicksEffect
|
|
43
|
+
| RaceWithinEffect
|
|
44
|
+
| ScheduleAtEffect
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _utc_now() -> datetime:
|
|
49
|
+
return datetime.now(timezone.utc)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _clock_wait(sleep: Callable[[float], Awaitable[Any]], seconds: float) -> Await:
|
|
53
|
+
"""An Await whose completion time is known: the scheduler does not count
|
|
54
|
+
it as stalled until ``seconds`` have passed (agora-redesign #765). The
|
|
55
|
+
deadline is on the scheduler's clock (``time.monotonic``), not on the
|
|
56
|
+
injectable ``monotonic`` that answers GetMonotonic."""
|
|
57
|
+
return Await(sleep(seconds), deadline=time.monotonic() + seconds)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AsyncTimeRuntime:
|
|
61
|
+
"""Runtime container for async wall-clock time effects."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
now: Callable[[], datetime],
|
|
67
|
+
sleep: Callable[[float], Awaitable[Any]],
|
|
68
|
+
monotonic: Callable[[], float],
|
|
69
|
+
) -> None:
|
|
70
|
+
self._now = now
|
|
71
|
+
self._sleep = sleep
|
|
72
|
+
self._monotonic = monotonic
|
|
73
|
+
|
|
74
|
+
@do
|
|
75
|
+
def _expire_after(self, seconds: float):
|
|
76
|
+
"""The deadline of a timed wait (WaitWithin・WaitTicks・RaceWithin): sleep ``seconds`` on the wall clock, then
|
|
77
|
+
answer DEADLINE_PASSED (a future may complete with None — RaceWithin)."""
|
|
78
|
+
yield _clock_wait(self._sleep, max(0.0, seconds))
|
|
79
|
+
return DEADLINE_PASSED
|
|
80
|
+
|
|
81
|
+
@do
|
|
82
|
+
def handle(self, effect: AsyncTimeEffect, k: Any):
|
|
83
|
+
# The annotation lets the VM skip this handler for every other effect, instead of
|
|
84
|
+
# calling it to Pass each one (agora card ki-1841b724d061 — every effect a records
|
|
85
|
+
# request sends past the clock cost a call here).
|
|
86
|
+
# Every clause performs its final Transfer/Pass from THIS frame.
|
|
87
|
+
# Delegating to a sub-@do that transfers (the pre-2026-07-14 shape)
|
|
88
|
+
# leaves this frame suspended mid-`yield` forever, pinning the
|
|
89
|
+
# Task handle and defeating the scheduler's terminal-entry sweep
|
|
90
|
+
# (ADR-DOE-CORE-EFFECTS-002).
|
|
91
|
+
if isinstance(effect, DelayEffect):
|
|
92
|
+
yield _clock_wait(self._sleep, max(0.0, effect.seconds))
|
|
93
|
+
return (yield Transfer(k, None))
|
|
94
|
+
if isinstance(effect, WaitUntilEffect):
|
|
95
|
+
wait_seconds = max(0.0, (effect.target - self._now()).total_seconds())
|
|
96
|
+
yield _clock_wait(self._sleep, wait_seconds)
|
|
97
|
+
return (yield Transfer(k, None))
|
|
98
|
+
if isinstance(effect, GetTimeEffect):
|
|
99
|
+
return (yield Transfer(k, self._now()))
|
|
100
|
+
if isinstance(effect, GetMonotonicEffect):
|
|
101
|
+
return (yield Transfer(k, float(self._monotonic())))
|
|
102
|
+
if isinstance(effect, (WaitWithinEffect, WaitTicksEffect, RaceWithinEffect)):
|
|
103
|
+
# The deadline is a daemon task sleeping on the wall clock, raced against the future(s) and
|
|
104
|
+
# cancelled afterwards (daemon: abandoning it at root return is its lifecycle — #501).
|
|
105
|
+
# WaitTicks' deadline is its last tick (agora-redesign #3066).
|
|
106
|
+
started = float(self._monotonic())
|
|
107
|
+
timer = yield Spawn(self._expire_after(timed_wait_seconds(effect)), daemon=True)
|
|
108
|
+
try:
|
|
109
|
+
first = yield Race(*timed_wait_futures(effect), timer, priority=PRIORITY_IDLE if effect.park else None)
|
|
110
|
+
finally:
|
|
111
|
+
yield Cancel(timer)
|
|
112
|
+
return (yield Transfer(k, timed_wait_answer(effect, first, float(self._monotonic()) - started)))
|
|
113
|
+
if isinstance(effect, ScheduleAtEffect):
|
|
114
|
+
wait_seconds = max(0.0, (effect.time - self._now()).total_seconds())
|
|
115
|
+
sleep = self._sleep
|
|
116
|
+
|
|
117
|
+
@do
|
|
118
|
+
def deferred():
|
|
119
|
+
yield _clock_wait(sleep, wait_seconds)
|
|
120
|
+
# Wait(task) answers the scheduled program's value (agora-redesign #1159).
|
|
121
|
+
return (yield effect.program)
|
|
122
|
+
|
|
123
|
+
# Resume the caller with the spawned Task (same contract as
|
|
124
|
+
# sim_time_handler) so failures of the deferred program can be
|
|
125
|
+
# observed via Wait/Gather instead of vanishing on an unwatched
|
|
126
|
+
# task (#503).
|
|
127
|
+
task = yield Spawn(deferred())
|
|
128
|
+
return (yield Transfer(k, task))
|
|
129
|
+
yield Pass(effect, k)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def async_time_handler(
|
|
133
|
+
*,
|
|
134
|
+
now: Callable[[], datetime] = _utc_now,
|
|
135
|
+
sleep: Callable[[float], Awaitable[Any]] = asyncio.sleep,
|
|
136
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
137
|
+
) -> ProgramHandler:
|
|
138
|
+
"""Return a protocol handler for wall-clock async time semantics."""
|
|
139
|
+
|
|
140
|
+
runtime = AsyncTimeRuntime(now=now, sleep=sleep, monotonic=monotonic)
|
|
141
|
+
return _program_handler(runtime.handle)
|
|
142
|
+
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Virtual-clock simulation handler for doeff-time effects."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from doeff_core_effects import WriterTellEffect
|
|
9
|
+
from doeff_core_effects.scheduler import (
|
|
10
|
+
PRIORITY_IDLE,
|
|
11
|
+
CompletePromise,
|
|
12
|
+
CreatePromise,
|
|
13
|
+
Future,
|
|
14
|
+
Race,
|
|
15
|
+
Spawn,
|
|
16
|
+
Task,
|
|
17
|
+
Wait,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from doeff import Pass, Transfer, do
|
|
21
|
+
from doeff import handler as _program_handler
|
|
22
|
+
from doeff.program import ProgramHandler
|
|
23
|
+
from doeff_time._internals import SimClock, TimeQueue
|
|
24
|
+
from doeff_time._internals.deadline import DEADLINE_PASSED
|
|
25
|
+
from doeff_time.effects import (
|
|
26
|
+
DelayEffect,
|
|
27
|
+
GetMonotonicEffect,
|
|
28
|
+
GetTimeEffect,
|
|
29
|
+
RaceOutcome,
|
|
30
|
+
RaceWithinEffect,
|
|
31
|
+
ScheduleAtEffect,
|
|
32
|
+
SetTimeEffect,
|
|
33
|
+
TicksOutcome,
|
|
34
|
+
WaitTicksEffect,
|
|
35
|
+
WaitUntilEffect,
|
|
36
|
+
WaitWithinEffect,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
ProtocolHandler = Callable[[Any, Any], Any]
|
|
40
|
+
LogFormatter = Callable[[datetime, Any], str]
|
|
41
|
+
EPOCH_UTC = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
42
|
+
#: The virtual clock's resolution (datetime keeps microseconds).
|
|
43
|
+
CLOCK_TICK = timedelta(microseconds=1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _delay_span(seconds: float) -> timedelta:
|
|
47
|
+
"""How far a Delay moves the virtual clock: a positive delay moves it by at least one tick.
|
|
48
|
+
|
|
49
|
+
``timedelta(seconds=s)`` rounds to whole microseconds, so a positive delay below half a
|
|
50
|
+
microsecond became zero and the clock did not move. A wait loop that sleeps "the rest of
|
|
51
|
+
its timeout" (e.g. doeff_records.watching.wait-for-changes) then slept the same sub-tick
|
|
52
|
+
remainder forever without advancing virtual time (agora-redesign #675, 2026-09-26: the
|
|
53
|
+
remainder 7.2e-08 s after float drift). A real sleep of a positive duration always lets at
|
|
54
|
+
least that much time pass, so the sim rounds such a delay up to one tick.
|
|
55
|
+
"""
|
|
56
|
+
span = timedelta(seconds=seconds)
|
|
57
|
+
if seconds > 0 and span <= timedelta(0):
|
|
58
|
+
return CLOCK_TICK
|
|
59
|
+
return span
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _TickRun:
|
|
63
|
+
"""A WaitTicks waiter's ticks on the virtual time queue (agora-redesign #3066): the spacing and the number
|
|
64
|
+
of ticks, how many have passed, and the sequence of the tick queued now (withdrawn when the future wins).
|
|
65
|
+
The clock driver advances it as each tick is taken off the queue; the waiting task reads ``passed``
|
|
66
|
+
once it is answered."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, every: timedelta, count: int) -> None:
|
|
69
|
+
self.every = every
|
|
70
|
+
self.count = count
|
|
71
|
+
self._mut_passed = 0
|
|
72
|
+
self._mut_sequence = 0
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def passed(self) -> int:
|
|
76
|
+
return self._mut_passed
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def sequence(self) -> int:
|
|
80
|
+
return self._mut_sequence
|
|
81
|
+
|
|
82
|
+
def queued(self, sequence: int) -> None:
|
|
83
|
+
"""Remember the sequence of the tick just queued."""
|
|
84
|
+
self._mut_sequence = sequence
|
|
85
|
+
|
|
86
|
+
def tick(self) -> bool:
|
|
87
|
+
"""Count a tick taken off the queue; answers whether it was the last one."""
|
|
88
|
+
self._mut_passed += 1
|
|
89
|
+
return self._mut_passed >= self.count
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SimTimeRuntime:
|
|
93
|
+
"""Runtime state for virtual-clock interpretation."""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
clock: SimClock,
|
|
99
|
+
log_formatter: LogFormatter | None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self._clock = clock
|
|
102
|
+
self._time_queue = TimeQueue()
|
|
103
|
+
# Queued ticks of WaitTicks waiters, by queue sequence: such an entry's promise is the waiter's
|
|
104
|
+
# deadline, completed only at its last tick (agora-redesign #3066).
|
|
105
|
+
self._mut_tick_runs: dict[int, _TickRun] = {}
|
|
106
|
+
self._mut_driver_running = False
|
|
107
|
+
self._log_formatter = log_formatter
|
|
108
|
+
self._mut_forwarding_tell = False
|
|
109
|
+
self._handler: ProtocolHandler = self.handle
|
|
110
|
+
|
|
111
|
+
@do
|
|
112
|
+
def _clock_driver(self):
|
|
113
|
+
"""Idle-priority daemon that advances time when normal tasks are parked.
|
|
114
|
+
|
|
115
|
+
A WaitTicks tick that is not the last is not completed: the next tick is queued the moment
|
|
116
|
+
this one is taken off — where a waiter re-registering WaitWithin at each tick would queue it,
|
|
117
|
+
since no other task runs between the pop and that waiter's re-registration — and the waiting
|
|
118
|
+
task is not woken (agora-redesign #3066)."""
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
while not self._time_queue.empty():
|
|
122
|
+
entry = self._time_queue.pop()
|
|
123
|
+
self._clock.advance_to(entry.time)
|
|
124
|
+
if entry.sequence in self._mut_tick_runs:
|
|
125
|
+
run = self._mut_tick_runs.pop(entry.sequence)
|
|
126
|
+
if not run.tick():
|
|
127
|
+
sequence = self._time_queue.push(entry.time + run.every, entry.promise)
|
|
128
|
+
run.queued(sequence)
|
|
129
|
+
self._mut_tick_runs[sequence] = run
|
|
130
|
+
continue
|
|
131
|
+
yield CompletePromise(entry.promise, entry.answer)
|
|
132
|
+
finally:
|
|
133
|
+
self._mut_driver_running = False
|
|
134
|
+
|
|
135
|
+
@do
|
|
136
|
+
def _ensure_clock_driver(self):
|
|
137
|
+
if self._mut_driver_running:
|
|
138
|
+
return None
|
|
139
|
+
self._mut_driver_running = True
|
|
140
|
+
# daemon=True carries two contracts:
|
|
141
|
+
# - #501: the driver's final IDLE resume (queued right after its
|
|
142
|
+
# last CompletePromise) is routinely abandoned when the root body
|
|
143
|
+
# returns first — that is this daemon's lifecycle, not lost work,
|
|
144
|
+
# so it must not trip the root close-out diagnostic.
|
|
145
|
+
# - #505: daemon tasks are the only tasks the scheduler's
|
|
146
|
+
# PRIORITY_EXTERNAL_WAIT shield may starve, which is exactly what
|
|
147
|
+
# keeps this driver from advancing sim time past a pending
|
|
148
|
+
# external completion.
|
|
149
|
+
yield Spawn(self._clock_driver(), priority=PRIORITY_IDLE, daemon=True)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
@do
|
|
153
|
+
def _wait_for_time(self, target_time: datetime):
|
|
154
|
+
promise = yield CreatePromise()
|
|
155
|
+
self._time_queue.push(target_time, promise)
|
|
156
|
+
_ = yield self._ensure_clock_driver()
|
|
157
|
+
yield Wait(promise.future)
|
|
158
|
+
|
|
159
|
+
@do
|
|
160
|
+
def _wait_within(self, future: "Future[object]", seconds: float, park: bool):
|
|
161
|
+
"""Answer ``future``'s value, or None once ``seconds`` of virtual time pass first.
|
|
162
|
+
|
|
163
|
+
The deadline is a promise on this handler's time queue (like a Delay) raced against the
|
|
164
|
+
future — no timer task is spawned, and a deadline the future beat is withdrawn from the
|
|
165
|
+
queue so the clock driver never advances to it (agora-redesign #2618). ``park`` races
|
|
166
|
+
an external promise without shielding the clock driver, so the deadline can pass while
|
|
167
|
+
the promise is pending (agora-redesign #3054).
|
|
168
|
+
"""
|
|
169
|
+
deadline = yield CreatePromise()
|
|
170
|
+
sequence = self._time_queue.push(self._clock.current_time + _delay_span(seconds), deadline)
|
|
171
|
+
_ = yield self._ensure_clock_driver()
|
|
172
|
+
try:
|
|
173
|
+
first = yield Race(future, deadline.future, priority=PRIORITY_IDLE if park else None)
|
|
174
|
+
finally:
|
|
175
|
+
self._time_queue.withdraw(sequence)
|
|
176
|
+
return first
|
|
177
|
+
|
|
178
|
+
@do
|
|
179
|
+
def _race_within(self, futures: "tuple[Task[object] | Future[object], ...]", seconds: float, park: bool):
|
|
180
|
+
"""Answer the first of ``futures`` to complete, or the deadline once ``seconds`` of virtual time pass
|
|
181
|
+
first — as a RaceOutcome.
|
|
182
|
+
|
|
183
|
+
WaitWithin's deadline (no timer task — withdrawn when a future wins) raced against several futures.
|
|
184
|
+
The clock driver completes it with DEADLINE_PASSED, so a future that completes with None is not
|
|
185
|
+
taken for the deadline.
|
|
186
|
+
"""
|
|
187
|
+
deadline = yield CreatePromise()
|
|
188
|
+
sequence = self._time_queue.push(self._clock.current_time + _delay_span(seconds), deadline, answer=DEADLINE_PASSED)
|
|
189
|
+
_ = yield self._ensure_clock_driver()
|
|
190
|
+
try:
|
|
191
|
+
first = yield Race(*futures, deadline.future, priority=PRIORITY_IDLE if park else None)
|
|
192
|
+
finally:
|
|
193
|
+
self._time_queue.withdraw(sequence)
|
|
194
|
+
timed_out = first is DEADLINE_PASSED
|
|
195
|
+
return RaceOutcome(value=None if timed_out else first, timed_out=timed_out)
|
|
196
|
+
|
|
197
|
+
@do
|
|
198
|
+
def _wait_ticks(self, future: "Future[object]", every: float, count: int, park: bool):
|
|
199
|
+
"""Answer ``future``'s value, or None once ``count`` ticks spaced ``every`` seconds pass first,
|
|
200
|
+
with how many ticks passed (agora-redesign #3066).
|
|
201
|
+
|
|
202
|
+
One tick at a time is on the time queue: the clock driver takes it off and queues the next
|
|
203
|
+
one at once, without waking this task (see _clock_driver), so a tick instant orders against
|
|
204
|
+
the other timers as if this task re-registered WaitWithin at each tick. The last tick
|
|
205
|
+
completes the deadline. A tick the future beat is withdrawn, as WaitWithin's deadline.
|
|
206
|
+
"""
|
|
207
|
+
deadline = yield CreatePromise()
|
|
208
|
+
run = _TickRun(_delay_span(every), count)
|
|
209
|
+
sequence = self._time_queue.push(self._clock.current_time + run.every, deadline)
|
|
210
|
+
run.queued(sequence)
|
|
211
|
+
self._mut_tick_runs[sequence] = run
|
|
212
|
+
_ = yield self._ensure_clock_driver()
|
|
213
|
+
try:
|
|
214
|
+
first = yield Race(future, deadline.future, priority=PRIORITY_IDLE if park else None)
|
|
215
|
+
finally:
|
|
216
|
+
self._time_queue.withdraw(run.sequence)
|
|
217
|
+
if run.sequence in self._mut_tick_runs:
|
|
218
|
+
del self._mut_tick_runs[run.sequence]
|
|
219
|
+
return TicksOutcome(value=first, passed=run.passed)
|
|
220
|
+
|
|
221
|
+
def _timed_wait(self, effect: WaitWithinEffect[object] | WaitTicksEffect[object] | RaceWithinEffect[object]):
|
|
222
|
+
"""The Program that answers one of the timed waits (a sub-program that completes before the Transfer)."""
|
|
223
|
+
match effect:
|
|
224
|
+
case WaitWithinEffect():
|
|
225
|
+
return self._wait_within(effect.future, effect.seconds, effect.park)
|
|
226
|
+
case WaitTicksEffect():
|
|
227
|
+
return self._wait_ticks(effect.future, effect.every, effect.count, effect.park)
|
|
228
|
+
case RaceWithinEffect():
|
|
229
|
+
return self._race_within(effect.futures, effect.seconds, effect.park)
|
|
230
|
+
|
|
231
|
+
@do
|
|
232
|
+
def handle(
|
|
233
|
+
self,
|
|
234
|
+
effect: WriterTellEffect
|
|
235
|
+
| DelayEffect
|
|
236
|
+
| WaitUntilEffect
|
|
237
|
+
| GetTimeEffect
|
|
238
|
+
| GetMonotonicEffect
|
|
239
|
+
| ScheduleAtEffect
|
|
240
|
+
| SetTimeEffect
|
|
241
|
+
| WaitWithinEffect
|
|
242
|
+
| WaitTicksEffect
|
|
243
|
+
| RaceWithinEffect,
|
|
244
|
+
k: Any,
|
|
245
|
+
):
|
|
246
|
+
# The annotation is the clause list below: the VM skips this handler for
|
|
247
|
+
# every other effect without entering Python (doeff_vm._effect_types),
|
|
248
|
+
# the same as the trailing Pass.
|
|
249
|
+
# Every clause performs its final Transfer/Pass from THIS frame.
|
|
250
|
+
# Delegating to a sub-@do that transfers (the pre-2026-07-14 shape)
|
|
251
|
+
# leaves this frame suspended mid-`yield` forever, pinning the
|
|
252
|
+
# Task handle and defeating the scheduler's terminal-entry sweep
|
|
253
|
+
# (ADR-DOE-CORE-EFFECTS-002). Sub-programs that COMPLETE before
|
|
254
|
+
# the Transfer (_wait_for_time, _ensure_clock_driver) are fine.
|
|
255
|
+
if (
|
|
256
|
+
isinstance(effect, WriterTellEffect)
|
|
257
|
+
and self._log_formatter is not None
|
|
258
|
+
and not self._mut_forwarding_tell
|
|
259
|
+
):
|
|
260
|
+
formatted = self._log_formatter(self._clock.current_time, effect.msg)
|
|
261
|
+
self._mut_forwarding_tell = True
|
|
262
|
+
try:
|
|
263
|
+
result = yield WriterTellEffect(formatted)
|
|
264
|
+
finally:
|
|
265
|
+
self._mut_forwarding_tell = False
|
|
266
|
+
return (yield Transfer(k, result))
|
|
267
|
+
if isinstance(effect, DelayEffect):
|
|
268
|
+
target_time = self._clock.current_time + _delay_span(effect.seconds)
|
|
269
|
+
_ = yield self._wait_for_time(target_time)
|
|
270
|
+
return (yield Transfer(k, None))
|
|
271
|
+
if isinstance(effect, WaitUntilEffect):
|
|
272
|
+
target_time = max(self._clock.current_time, effect.target)
|
|
273
|
+
_ = yield self._wait_for_time(target_time)
|
|
274
|
+
return (yield Transfer(k, None))
|
|
275
|
+
if isinstance(effect, (GetTimeEffect, GetMonotonicEffect)):
|
|
276
|
+
now = self._clock.current_time
|
|
277
|
+
# GetMonotonic reads the virtual clock's POSIX timestamp: virtual
|
|
278
|
+
# time only moves through Delay/WaitUntil/SetTime, and callers use
|
|
279
|
+
# differences between readings.
|
|
280
|
+
reading = now if isinstance(effect, GetTimeEffect) else now.timestamp()
|
|
281
|
+
return (yield Transfer(k, reading))
|
|
282
|
+
if isinstance(effect, (WaitWithinEffect, WaitTicksEffect, RaceWithinEffect)):
|
|
283
|
+
answer = yield self._timed_wait(effect)
|
|
284
|
+
return (yield Transfer(k, answer))
|
|
285
|
+
if isinstance(effect, ScheduleAtEffect):
|
|
286
|
+
|
|
287
|
+
@do
|
|
288
|
+
def deferred():
|
|
289
|
+
_ = yield self._wait_for_time(effect.time)
|
|
290
|
+
# Wait(task) answers the scheduled program's value (agora-redesign #1159).
|
|
291
|
+
return (yield effect.program)
|
|
292
|
+
|
|
293
|
+
task = yield Spawn(deferred())
|
|
294
|
+
return (yield Transfer(k, task))
|
|
295
|
+
if isinstance(effect, SetTimeEffect):
|
|
296
|
+
self._clock.set_time(effect.time)
|
|
297
|
+
if not self._time_queue.empty():
|
|
298
|
+
_ = yield self._ensure_clock_driver()
|
|
299
|
+
return (yield Transfer(k, None))
|
|
300
|
+
yield Pass(effect, k)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def sim_time_handler(
|
|
304
|
+
*,
|
|
305
|
+
start_time: datetime | None = None,
|
|
306
|
+
clock: SimClock | None = None,
|
|
307
|
+
log_formatter: LogFormatter | None = None,
|
|
308
|
+
) -> ProgramHandler:
|
|
309
|
+
"""Return a virtual-clock handler that delegates core concurrency effects.
|
|
310
|
+
|
|
311
|
+
Install it inside ``scheduled`` and outside every ``Spawn`` whose tasks
|
|
312
|
+
sleep on it: spawned tasks inherit the handler, so all of them share one
|
|
313
|
+
virtual clock. Time advances to the earliest pending wake-up only when no
|
|
314
|
+
normal-priority task is runnable; wake-ups at the same instant resume in
|
|
315
|
+
the order they were requested.
|
|
316
|
+
|
|
317
|
+
``clock`` lets the caller own the ``SimClock`` so tests can read
|
|
318
|
+
``clock.current_time`` synchronously (or move it with ``set_time`` between
|
|
319
|
+
runs). Pass either ``start_time`` or ``clock``, not both.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
if clock is not None and start_time is not None:
|
|
323
|
+
raise ValueError("pass either start_time or clock, not both")
|
|
324
|
+
if clock is None:
|
|
325
|
+
clock = SimClock(EPOCH_UTC if start_time is None else start_time)
|
|
326
|
+
runtime = SimTimeRuntime(clock=clock, log_formatter=log_formatter)
|
|
327
|
+
return _program_handler(runtime._handler)
|
|
328
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Blocking wall-clock handler for doeff-time effects."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from doeff_core_effects.scheduler import PRIORITY_IDLE, CreateExternalPromise, Race, Spawn
|
|
11
|
+
from doeff_core_effects.scheduler import Wait as WaitTask
|
|
12
|
+
|
|
13
|
+
from doeff import Pass, Transfer, do
|
|
14
|
+
from doeff import handler as _program_handler
|
|
15
|
+
from doeff.program import ProgramHandler
|
|
16
|
+
from doeff_time._internals.deadline import DEADLINE_PASSED
|
|
17
|
+
from doeff_time.effects import (
|
|
18
|
+
DelayEffect,
|
|
19
|
+
GetMonotonicEffect,
|
|
20
|
+
GetTimeEffect,
|
|
21
|
+
RaceWithinEffect,
|
|
22
|
+
ScheduleAtEffect,
|
|
23
|
+
WaitTicksEffect,
|
|
24
|
+
WaitUntilEffect,
|
|
25
|
+
WaitWithinEffect,
|
|
26
|
+
)
|
|
27
|
+
from doeff_time.handlers._wall_ticks import (
|
|
28
|
+
timed_wait_answer,
|
|
29
|
+
timed_wait_futures,
|
|
30
|
+
timed_wait_seconds,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
ProtocolHandler = Callable[[Any, Any], Any]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _utc_now() -> datetime:
|
|
37
|
+
return datetime.now(timezone.utc)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SyncTimeRuntime:
|
|
41
|
+
"""Runtime container for sync wall-clock time effects."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
now: Callable[[], datetime],
|
|
47
|
+
sleep: Callable[[float], None],
|
|
48
|
+
monotonic: Callable[[], float],
|
|
49
|
+
) -> None:
|
|
50
|
+
self._now = now
|
|
51
|
+
self._sleep = sleep
|
|
52
|
+
self._monotonic = monotonic
|
|
53
|
+
|
|
54
|
+
@do
|
|
55
|
+
def handle(self, effect: Any, k: Any):
|
|
56
|
+
# Every clause performs its final Transfer/Pass from THIS frame.
|
|
57
|
+
# Delegating to a sub-@do that transfers (the pre-2026-07-14 shape)
|
|
58
|
+
# leaves this frame suspended mid-`yield` forever, pinning the
|
|
59
|
+
# Task handle and defeating the scheduler's terminal-entry sweep
|
|
60
|
+
# (ADR-DOE-CORE-EFFECTS-002).
|
|
61
|
+
if isinstance(effect, DelayEffect):
|
|
62
|
+
self._sleep(max(0.0, effect.seconds))
|
|
63
|
+
return (yield Transfer(k, None))
|
|
64
|
+
if isinstance(effect, WaitUntilEffect):
|
|
65
|
+
self._sleep(max(0.0, (effect.target - self._now()).total_seconds()))
|
|
66
|
+
return (yield Transfer(k, None))
|
|
67
|
+
if isinstance(effect, GetTimeEffect):
|
|
68
|
+
return (yield Transfer(k, self._now()))
|
|
69
|
+
if isinstance(effect, GetMonotonicEffect):
|
|
70
|
+
return (yield Transfer(k, float(self._monotonic())))
|
|
71
|
+
if isinstance(effect, (WaitWithinEffect, WaitTicksEffect, RaceWithinEffect)):
|
|
72
|
+
# The deadline is an external promise a wall-clock timer completes (the scheduler knows
|
|
73
|
+
# when it wakes — #765), raced against the future(s); the timer is stopped afterwards.
|
|
74
|
+
# WaitTicks' deadline is its last tick (agora-redesign #3066).
|
|
75
|
+
started = float(self._monotonic())
|
|
76
|
+
seconds = timed_wait_seconds(effect)
|
|
77
|
+
deadline = yield CreateExternalPromise(deadline=time.monotonic() + seconds)
|
|
78
|
+
|
|
79
|
+
def _deadline_passed():
|
|
80
|
+
deadline.complete(DEADLINE_PASSED)
|
|
81
|
+
|
|
82
|
+
timer = threading.Timer(seconds, _deadline_passed)
|
|
83
|
+
timer.daemon = True
|
|
84
|
+
timer.start()
|
|
85
|
+
try:
|
|
86
|
+
first = yield Race(*timed_wait_futures(effect), deadline.future, priority=PRIORITY_IDLE if effect.park else None)
|
|
87
|
+
finally:
|
|
88
|
+
timer.cancel()
|
|
89
|
+
return (yield Transfer(k, timed_wait_answer(effect, first, float(self._monotonic()) - started)))
|
|
90
|
+
if isinstance(effect, ScheduleAtEffect):
|
|
91
|
+
wait_seconds = max(0.0, (effect.time - self._now()).total_seconds())
|
|
92
|
+
|
|
93
|
+
@do
|
|
94
|
+
def deferred():
|
|
95
|
+
# A timer wait: the scheduler knows when it wakes (#765).
|
|
96
|
+
ep = yield CreateExternalPromise(deadline=time.monotonic() + wait_seconds)
|
|
97
|
+
|
|
98
|
+
def _timer_done():
|
|
99
|
+
ep.complete(None)
|
|
100
|
+
|
|
101
|
+
timer = threading.Timer(wait_seconds, _timer_done)
|
|
102
|
+
timer.daemon = True
|
|
103
|
+
timer.start()
|
|
104
|
+
yield WaitTask(ep.future)
|
|
105
|
+
# Wait(task) answers the scheduled program's value (agora-redesign #1159).
|
|
106
|
+
return (yield effect.program)
|
|
107
|
+
|
|
108
|
+
# Resume the caller with the spawned Task (same contract as
|
|
109
|
+
# sim_time_handler) so failures of the deferred program can be
|
|
110
|
+
# observed via Wait/Gather instead of vanishing on an unwatched
|
|
111
|
+
# task (#503).
|
|
112
|
+
task = yield Spawn(deferred())
|
|
113
|
+
return (yield Transfer(k, task))
|
|
114
|
+
yield Pass(effect, k)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def sync_time_handler(
|
|
118
|
+
*,
|
|
119
|
+
now: Callable[[], datetime] = _utc_now,
|
|
120
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
121
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
122
|
+
) -> ProgramHandler:
|
|
123
|
+
"""Return a protocol handler for blocking wall-clock time semantics."""
|
|
124
|
+
|
|
125
|
+
runtime = SyncTimeRuntime(now=now, sleep=sleep, monotonic=monotonic)
|
|
126
|
+
return _program_handler(runtime.handle)
|
|
127
|
+
|
doeff_time/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: doeff-time
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Provider-agnostic time effects for doeff
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: doeff-core-effects<0.9,>=0.8.0
|
|
7
|
+
Requires-Dist: doeff<0.9,>=0.8.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# doeff-time
|
|
11
|
+
|
|
12
|
+
Provider-agnostic time effects for `doeff`.
|
|
13
|
+
|
|
14
|
+
## Effects
|
|
15
|
+
|
|
16
|
+
- `Delay(seconds)`
|
|
17
|
+
- `WaitUntil(target: datetime)` (`target` must be timezone-aware)
|
|
18
|
+
- `GetTime()`
|
|
19
|
+
- `GetMonotonic()` — monotonic seconds (`float`); only differences are meaningful
|
|
20
|
+
- `ScheduleAt(time: datetime, program)` (`time` must be timezone-aware) — answers the spawned `Task`;
|
|
21
|
+
`Wait(task)` answers `program`'s value, or raises its failure
|
|
22
|
+
- `SetTime(time: datetime)` (for simulation handlers, `time` must be timezone-aware)
|
|
23
|
+
|
|
24
|
+
## Conversions
|
|
25
|
+
|
|
26
|
+
- `epoch_ms_of(at: datetime) -> int` — timezone-aware time → epoch milliseconds, floored by integer
|
|
27
|
+
`timedelta` division (no float on the way). This is the one rounding for every stamp that is laid
|
|
28
|
+
next to another stamp: two places rounding the same instant differently (round vs floor) make a
|
|
29
|
+
write at 329.6 ms look 1 ms later than a read at 329.9 ms.
|
|
30
|
+
|
|
31
|
+
## Handlers
|
|
32
|
+
|
|
33
|
+
- `async_time_handler()` for `asyncio` runtimes
|
|
34
|
+
- `sync_time_handler()` for blocking runtimes
|
|
35
|
+
- `sim_time_handler(start_time=...)` for deterministic virtual time (`start_time` is timezone-aware
|
|
36
|
+
`datetime`), or `sim_time_handler(clock=SimClock(start))` when the caller wants to read or move
|
|
37
|
+
the virtual clock outside the program (tests)
|
|
38
|
+
|
|
39
|
+
### Shared contract (all three handlers)
|
|
40
|
+
|
|
41
|
+
`tests/test_time_contract.hy` runs the same deftests under each handler (`:interpreters
|
|
42
|
+
["async" "sync" "sim"]`): readings never go back, `Delay(d)` / `WaitUntil(t)` advance the clock by
|
|
43
|
+
`d` / up to `t` (exactly on the virtual clock, with a small jitter bound on the wall clock), past
|
|
44
|
+
targets return at once, other effects pass through, and `ScheduleAt` tasks run in time order and
|
|
45
|
+
answer their program's value or failure through `Wait`. `SetTime` is simulation-only and stays out
|
|
46
|
+
of the shared contract. Note: the scheduled program runs under the handlers outside the time
|
|
47
|
+
handler, so it cannot itself perform time effects.
|
|
48
|
+
|
|
49
|
+
### Virtual time contract (`sim_time_handler`)
|
|
50
|
+
|
|
51
|
+
- Install inside `scheduled(...)` and outside every `Spawn` whose tasks sleep on it; spawned tasks
|
|
52
|
+
inherit the handler and share one clock.
|
|
53
|
+
- Time advances to the earliest pending wake-up only when no normal-priority task is runnable, so a
|
|
54
|
+
task woken at `t` reads `t` however many scheduler steps it takes.
|
|
55
|
+
- Wake-ups at the same instant resume in the order they were requested; the trace is identical on
|
|
56
|
+
the Rust scheduler (the default) and the Python one (`DOEFF_SCHEDULER=python`).
|
|
57
|
+
- The run ends when the root program returns, even while daemon services are still sleeping.
|
|
58
|
+
- `GetMonotonic()` answers the virtual clock's POSIX timestamp.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
doeff_time/__init__.py,sha256=NYdLE7Fq_wHdsXRlRZYMMYNekBdyVOkVRSU1yk7-xzU,2110
|
|
2
|
+
doeff_time/epoch_ms.py,sha256=Bo_QO4pCYjyxiQkGK1j5xQKig26uhMu1kaz930hIjDc,1116
|
|
3
|
+
doeff_time/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
4
|
+
doeff_time/_internals/__init__.py,sha256=x6CLm0HU6ekQPaxG-hG9lYr3AR_t5gw6NGg-2rcR4AE,196
|
|
5
|
+
doeff_time/_internals/deadline.py,sha256=fFeKYDnV8tYa0-IcrNtep1pU9ptNat339xmfWCOh9Kw,423
|
|
6
|
+
doeff_time/_internals/sim_clock.py,sha256=3rYp861I3W292Vvtv8PbOSIXj4ccX4ef5C7TCRMlHqU,799
|
|
7
|
+
doeff_time/_internals/time_queue.py,sha256=G-5KmcXJkFeteg_aeSWheOGzETv-ZisY33Uq4RYPn5k,2829
|
|
8
|
+
doeff_time/_internals/validation.py,sha256=QOCZVDP42fsiRNxzTqinegiHK3TVTQ6-6_LaBcxFH3c,388
|
|
9
|
+
doeff_time/effects/__init__.py,sha256=3Ih-0f41D_81jsLmlmSrF05zxhSfKrT05FhJJNItUr4,1752
|
|
10
|
+
doeff_time/effects/time.py,sha256=qZrhy1WWlxsCAb-YGa5MV_785IzzEn4GTp0Y8-DQAdA,9542
|
|
11
|
+
doeff_time/handlers/__init__.py,sha256=Qu3t9f1iJw4sqMYoD1coAR7PgkEHsB_l2DFaSx_ttGU,293
|
|
12
|
+
doeff_time/handlers/_wall_ticks.py,sha256=-ONTYUbmd_FUFKa0rjehPJWV_vEbmzWmiEu78nTdcJY,2522
|
|
13
|
+
doeff_time/handlers/async_time.py,sha256=kGIBFM_eMLQzumHuIiOfOV2QXbuehl3I0ll0ggvaEaQ,5557
|
|
14
|
+
doeff_time/handlers/sim_time.py,sha256=V879gET0oJFSK2oeV4UMiFv8aVftZlxZgRwyn0M5KHM,14222
|
|
15
|
+
doeff_time/handlers/sync_time.py,sha256=_a2C4cQiHBKxqpxxLKUbTnSdxTX6uUa33d5qlAwycnY,4773
|
|
16
|
+
doeff_time-0.1.0.dist-info/METADATA,sha256=rNgXXz4ihxbclo7-NQP1J1enZWwbmpWeOLNMy7CZ5K8,2854
|
|
17
|
+
doeff_time-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
18
|
+
doeff_time-0.1.0.dist-info/RECORD,,
|