flpit 0.2.1.dev5__py3-none-any.whl → 0.2.1.dev6__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.
- flpit/task.py +1577 -0
- {flpit-0.2.1.dev5.dist-info → flpit-0.2.1.dev6.dist-info}/METADATA +1 -1
- {flpit-0.2.1.dev5.dist-info → flpit-0.2.1.dev6.dist-info}/RECORD +5 -4
- {flpit-0.2.1.dev5.dist-info → flpit-0.2.1.dev6.dist-info}/WHEEL +0 -0
- {flpit-0.2.1.dev5.dist-info → flpit-0.2.1.dev6.dist-info}/licenses/LICENSE +0 -0
flpit/task.py
ADDED
|
@@ -0,0 +1,1577 @@
|
|
|
1
|
+
"""Task: a small asyncio-free Task/awaitable runtime.
|
|
2
|
+
|
|
3
|
+
This is a deliberately small runtime, not an asyncio compatibility layer.
|
|
4
|
+
|
|
5
|
+
Public API
|
|
6
|
+
----------
|
|
7
|
+
Task.configure(max_workers=8, max_pending_background=128)
|
|
8
|
+
Task.run(async_function, *args, token=token)
|
|
9
|
+
Task.run(blocking_function, *args, token=token, pass_token=True)
|
|
10
|
+
await Task.delay(0.5, token=token)
|
|
11
|
+
await IO.wait_readable(sock, token=token)
|
|
12
|
+
await IO.wait_writable(sock, token=token)
|
|
13
|
+
|
|
14
|
+
cts = CancellationTokenSource()
|
|
15
|
+
linked = CancellationTokenSource.create_linked_token_source(cts.token, other.token)
|
|
16
|
+
cts.cancel()
|
|
17
|
+
|
|
18
|
+
There is no application-managed Runtime object and no asyncio dependency. The
|
|
19
|
+
runtime is process-wide and starts lazily. Call Task.shutdown() for an explicit,
|
|
20
|
+
observable shutdown; it can be started again by subsequent Task.run calls.
|
|
21
|
+
|
|
22
|
+
Cancellation is cooperative for coroutines and worker functions. A worker can
|
|
23
|
+
receive the same token with pass_token=True and should check it between units
|
|
24
|
+
of work. Python cannot safely force-stop an arbitrary running thread.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import atexit
|
|
29
|
+
import heapq
|
|
30
|
+
import inspect
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
import queue
|
|
34
|
+
import selectors
|
|
35
|
+
import socket
|
|
36
|
+
import threading
|
|
37
|
+
import time
|
|
38
|
+
from collections import deque
|
|
39
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from itertools import count
|
|
42
|
+
from typing import Any, Callable, Coroutine, Generic, TypeVar, cast, Iterable
|
|
43
|
+
import functools
|
|
44
|
+
|
|
45
|
+
T = TypeVar("T")
|
|
46
|
+
_LOG = logging.getLogger("tinytask")
|
|
47
|
+
_MISSING = object()
|
|
48
|
+
_WAKE = object()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OperationCanceledError(BaseException):
|
|
52
|
+
"""Raised when cancellation is delivered to an operation or coroutine."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InvalidStateError(RuntimeError):
|
|
56
|
+
"""An operation was requested before a Task reached a valid state."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class BackgroundQueueFull(RuntimeError):
|
|
60
|
+
"""Raised when the bounded background-work queue has no free capacity."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RuntimeShutdownError(RuntimeError):
|
|
64
|
+
"""Raised when new work is submitted while the singleton runtime is stopping."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AggregateError(Exception):
|
|
68
|
+
"""Raised by Task.when_all() when multiple child tasks fail.
|
|
69
|
+
|
|
70
|
+
The original exceptions are available in the ``exceptions`` tuple. A single
|
|
71
|
+
child failure is re-raised directly to keep the common case ergonomic.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
exceptions: Iterable[Exception],
|
|
77
|
+
message: str = "One or more errors occurred.",
|
|
78
|
+
) -> None:
|
|
79
|
+
self.exceptions = tuple(exceptions)
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class ShutdownReport:
|
|
85
|
+
"""Outcome of a runtime shutdown request."""
|
|
86
|
+
|
|
87
|
+
runtime_stopped: bool
|
|
88
|
+
tasks_cancelled: int
|
|
89
|
+
workers_still_running: int
|
|
90
|
+
timed_out: bool
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _CancellationState:
|
|
94
|
+
"""Thread-safe cancellation state shared by tokens and their source."""
|
|
95
|
+
|
|
96
|
+
def __init__(self) -> None:
|
|
97
|
+
self.lock = threading.RLock()
|
|
98
|
+
self.cancelled = False
|
|
99
|
+
self.callbacks: dict[int, Callable[[], Any]] = {}
|
|
100
|
+
self.next_id = count(1)
|
|
101
|
+
|
|
102
|
+
def register(self, callback: Callable[[], Any]) -> "CancellationTokenRegistration":
|
|
103
|
+
with self.lock:
|
|
104
|
+
if not self.cancelled:
|
|
105
|
+
key = next(self.next_id)
|
|
106
|
+
self.callbacks[key] = callback
|
|
107
|
+
|
|
108
|
+
return CancellationTokenRegistration(self, key)
|
|
109
|
+
|
|
110
|
+
exception = _invoke_cancellation_callback(callback)
|
|
111
|
+
|
|
112
|
+
if exception is not None:
|
|
113
|
+
raise exception
|
|
114
|
+
|
|
115
|
+
return CancellationTokenRegistration(None, None)
|
|
116
|
+
|
|
117
|
+
def unregister(self, key: int) -> None:
|
|
118
|
+
with self.lock:
|
|
119
|
+
self.callbacks.pop(key, None)
|
|
120
|
+
|
|
121
|
+
def cancel(self) -> bool:
|
|
122
|
+
with self.lock:
|
|
123
|
+
|
|
124
|
+
if self.cancelled:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
self.cancelled = True
|
|
128
|
+
callbacks = list(reversed(self.callbacks.values()))
|
|
129
|
+
self.callbacks.clear()
|
|
130
|
+
|
|
131
|
+
exceptions: list[Exception] = []
|
|
132
|
+
|
|
133
|
+
for callback in callbacks:
|
|
134
|
+
exception = _invoke_cancellation_callback(callback)
|
|
135
|
+
if exception is not None:
|
|
136
|
+
exceptions.append(exception)
|
|
137
|
+
|
|
138
|
+
if exceptions:
|
|
139
|
+
raise AggregateError(exceptions)
|
|
140
|
+
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _invoke_cancellation_callback(callback: Callable[[], Any]) -> Exception | None:
|
|
145
|
+
try:
|
|
146
|
+
callback()
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
return exc
|
|
149
|
+
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class CancellationTokenRegistration:
|
|
154
|
+
"""Disposable registration returned by CancellationToken.register()."""
|
|
155
|
+
|
|
156
|
+
__slots__ = ("_state", "_key", "_disposed", "_lock")
|
|
157
|
+
|
|
158
|
+
def __init__(self, state: _CancellationState | None, key: int | None) -> None:
|
|
159
|
+
self._state = state
|
|
160
|
+
self._key = key
|
|
161
|
+
self._disposed = False
|
|
162
|
+
self._lock = threading.Lock()
|
|
163
|
+
|
|
164
|
+
def dispose(self) -> None:
|
|
165
|
+
with self._lock:
|
|
166
|
+
if self._disposed:
|
|
167
|
+
return
|
|
168
|
+
self._disposed = True
|
|
169
|
+
state, key = self._state, self._key
|
|
170
|
+
self._state = None
|
|
171
|
+
self._key = None
|
|
172
|
+
if state is not None and key is not None:
|
|
173
|
+
state.unregister(key)
|
|
174
|
+
|
|
175
|
+
close = dispose
|
|
176
|
+
|
|
177
|
+
def __enter__(self) -> "CancellationTokenRegistration":
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
def __exit__(self, *_: Any) -> None:
|
|
181
|
+
self.dispose()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class CancellationToken:
|
|
185
|
+
"""Read-only cancellation handle. Create cancellable tokens via a source."""
|
|
186
|
+
|
|
187
|
+
__slots__ = ("_state",)
|
|
188
|
+
|
|
189
|
+
def __init__(self, state: _CancellationState | None = None) -> None:
|
|
190
|
+
# A public no-argument token is intentionally non-cancellable, matching
|
|
191
|
+
# the role of CancellationToken.none(). Sources create tokens with state.
|
|
192
|
+
self._state = state
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def none(cls) -> "CancellationToken":
|
|
196
|
+
return _NONE_TOKEN
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def can_be_cancelled(self) -> bool:
|
|
200
|
+
return self._state is not None
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def is_cancellation_requested(self) -> bool:
|
|
204
|
+
state = self._state
|
|
205
|
+
if state is None:
|
|
206
|
+
return False
|
|
207
|
+
with state.lock:
|
|
208
|
+
return state.cancelled
|
|
209
|
+
|
|
210
|
+
def throw_if_cancellation_requested(self) -> None:
|
|
211
|
+
if self.is_cancellation_requested:
|
|
212
|
+
raise OperationCanceledError("Cancellation was requested")
|
|
213
|
+
|
|
214
|
+
def register(self, callback: Callable[[], Any]) -> CancellationTokenRegistration:
|
|
215
|
+
if not callable(callback):
|
|
216
|
+
raise TypeError("callback must be callable")
|
|
217
|
+
state = self._state
|
|
218
|
+
if state is None:
|
|
219
|
+
return CancellationTokenRegistration(None, None)
|
|
220
|
+
return state.register(callback)
|
|
221
|
+
|
|
222
|
+
def __repr__(self) -> str:
|
|
223
|
+
return (
|
|
224
|
+
"CancellationToken(can_be_cancelled=False)"
|
|
225
|
+
if self._state is None
|
|
226
|
+
else f"CancellationToken(cancelled={self.is_cancellation_requested})"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
_NONE_TOKEN = CancellationToken()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class CancellationTokenSource:
|
|
234
|
+
"""Owns cancellation state and provides a token consumers can observe."""
|
|
235
|
+
|
|
236
|
+
def __init__(self) -> None:
|
|
237
|
+
self._state = _CancellationState()
|
|
238
|
+
self._token = CancellationToken(self._state)
|
|
239
|
+
self._lock = threading.RLock()
|
|
240
|
+
self._disposed = False
|
|
241
|
+
self._timer: threading.Timer | None = None
|
|
242
|
+
self._linked_registrations: list[CancellationTokenRegistration] = []
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def token(self) -> CancellationToken:
|
|
246
|
+
return self._token
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def is_cancellation_requested(self) -> bool:
|
|
250
|
+
return self._token.is_cancellation_requested
|
|
251
|
+
|
|
252
|
+
def cancel(self) -> bool:
|
|
253
|
+
with self._lock:
|
|
254
|
+
self._throw_if_disposed()
|
|
255
|
+
timer = self._timer
|
|
256
|
+
self._timer = None
|
|
257
|
+
links = list(self._linked_registrations)
|
|
258
|
+
self._linked_registrations.clear()
|
|
259
|
+
if timer is not None:
|
|
260
|
+
timer.cancel()
|
|
261
|
+
cancelled = self._state.cancel()
|
|
262
|
+
# A linked source no longer needs to hold registrations after its own
|
|
263
|
+
# cancellation has fired. This is safe even when called from a link callback.
|
|
264
|
+
for registration in links:
|
|
265
|
+
registration.dispose()
|
|
266
|
+
return cancelled
|
|
267
|
+
|
|
268
|
+
def cancel_after(self, seconds: float | None) -> None:
|
|
269
|
+
"""Request cancellation after seconds; None or -1 disables the timer."""
|
|
270
|
+
with self._lock:
|
|
271
|
+
self._throw_if_disposed()
|
|
272
|
+
old_timer = self._timer
|
|
273
|
+
self._timer = None
|
|
274
|
+
if seconds is not None and seconds != -1:
|
|
275
|
+
if seconds < 0:
|
|
276
|
+
raise ValueError("seconds must be >= 0, -1, or None")
|
|
277
|
+
timer = threading.Timer(seconds, self._cancel_from_timer)
|
|
278
|
+
timer.daemon = True
|
|
279
|
+
self._timer = timer
|
|
280
|
+
else:
|
|
281
|
+
timer = None
|
|
282
|
+
if old_timer is not None:
|
|
283
|
+
old_timer.cancel()
|
|
284
|
+
if timer is not None:
|
|
285
|
+
timer.start()
|
|
286
|
+
|
|
287
|
+
def _cancel_from_timer(self) -> None:
|
|
288
|
+
try:
|
|
289
|
+
self.cancel()
|
|
290
|
+
except RuntimeError:
|
|
291
|
+
# The source was disposed concurrently with its timer firing.
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
@classmethod
|
|
295
|
+
def create_linked_token_source(
|
|
296
|
+
cls, *tokens: CancellationToken
|
|
297
|
+
) -> "CancellationTokenSource":
|
|
298
|
+
if any(not isinstance(token, CancellationToken) for token in tokens):
|
|
299
|
+
raise TypeError("all linked values must be CancellationToken instances")
|
|
300
|
+
linked = cls()
|
|
301
|
+
for token in tokens:
|
|
302
|
+
registration = token.register(linked._cancel_from_link)
|
|
303
|
+
linked._linked_registrations.append(registration)
|
|
304
|
+
# If the source token was already cancelled, the callback executes
|
|
305
|
+
# synchronously during register() and the linked source is now cancelled.
|
|
306
|
+
if linked.is_cancellation_requested:
|
|
307
|
+
break
|
|
308
|
+
if linked.is_cancellation_requested:
|
|
309
|
+
linked._dispose_link_registrations()
|
|
310
|
+
return linked
|
|
311
|
+
|
|
312
|
+
def _cancel_from_link(self) -> None:
|
|
313
|
+
try:
|
|
314
|
+
self.cancel()
|
|
315
|
+
except RuntimeError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
def _dispose_link_registrations(self) -> None:
|
|
319
|
+
with self._lock:
|
|
320
|
+
links = list(self._linked_registrations)
|
|
321
|
+
self._linked_registrations.clear()
|
|
322
|
+
for registration in links:
|
|
323
|
+
registration.dispose()
|
|
324
|
+
|
|
325
|
+
def dispose(self) -> None:
|
|
326
|
+
with self._lock:
|
|
327
|
+
if self._disposed:
|
|
328
|
+
return
|
|
329
|
+
self._disposed = True
|
|
330
|
+
timer = self._timer
|
|
331
|
+
self._timer = None
|
|
332
|
+
links = list(self._linked_registrations)
|
|
333
|
+
self._linked_registrations.clear()
|
|
334
|
+
if timer is not None:
|
|
335
|
+
timer.cancel()
|
|
336
|
+
for registration in links:
|
|
337
|
+
registration.dispose()
|
|
338
|
+
|
|
339
|
+
close = dispose
|
|
340
|
+
|
|
341
|
+
def _throw_if_disposed(self) -> None:
|
|
342
|
+
if self._disposed:
|
|
343
|
+
raise RuntimeError("CancellationTokenSource has been disposed")
|
|
344
|
+
|
|
345
|
+
def __enter__(self) -> "CancellationTokenSource":
|
|
346
|
+
self._throw_if_disposed()
|
|
347
|
+
return self
|
|
348
|
+
|
|
349
|
+
def __exit__(self, *_: Any) -> None:
|
|
350
|
+
self.dispose()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class _Operation:
|
|
354
|
+
"""Instruction yielded by an awaitable and consumed by the scheduler."""
|
|
355
|
+
|
|
356
|
+
def __init__(self, runtime: "_Runtime", token: CancellationToken | None = None) -> None:
|
|
357
|
+
self.runtime = runtime
|
|
358
|
+
self.token = token or CancellationToken.none()
|
|
359
|
+
self._token_registration: CancellationTokenRegistration | None = None
|
|
360
|
+
|
|
361
|
+
def __await__(self):
|
|
362
|
+
result = yield self
|
|
363
|
+
return result
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class _Sleep(_Operation):
|
|
367
|
+
def __init__(self, runtime: "_Runtime", delay: float) -> None:
|
|
368
|
+
super().__init__(runtime)
|
|
369
|
+
self.delay = delay
|
|
370
|
+
self.deadline = time.monotonic() + delay
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class _YieldNow(_Operation):
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class _WaitIO(_Operation):
|
|
378
|
+
def __init__(
|
|
379
|
+
self, runtime: "_Runtime", sock: socket.socket, mode: str,
|
|
380
|
+
token: CancellationToken | None = None,
|
|
381
|
+
) -> None:
|
|
382
|
+
super().__init__(runtime, token)
|
|
383
|
+
self.sock = sock
|
|
384
|
+
self.mode = mode
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class _WaitTask(_Operation):
|
|
388
|
+
def __init__(self, runtime: "_Runtime", task: "Task[Any]") -> None:
|
|
389
|
+
super().__init__(runtime)
|
|
390
|
+
self.task = task
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class _WaitMany(_Operation):
|
|
394
|
+
"""Wait for all or any of a set of tasks to complete."""
|
|
395
|
+
|
|
396
|
+
def __init__(
|
|
397
|
+
self, runtime: "_Runtime", tasks: tuple["Task[Any]", ...], mode: str
|
|
398
|
+
) -> None:
|
|
399
|
+
super().__init__(runtime)
|
|
400
|
+
if mode not in ("all", "any"):
|
|
401
|
+
raise ValueError("mode must be 'all' or 'any'")
|
|
402
|
+
self.tasks = tasks
|
|
403
|
+
self.mode = mode
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class _BackgroundCall(_Operation):
|
|
407
|
+
def __init__(
|
|
408
|
+
self,
|
|
409
|
+
runtime: "_Runtime",
|
|
410
|
+
function: Callable[..., Any],
|
|
411
|
+
args: tuple[Any, ...],
|
|
412
|
+
kwargs: dict[str, Any],
|
|
413
|
+
) -> None:
|
|
414
|
+
super().__init__(runtime)
|
|
415
|
+
self.function = function
|
|
416
|
+
self.args = args
|
|
417
|
+
self.kwargs = kwargs
|
|
418
|
+
self.future: Future[Any] | None = None
|
|
419
|
+
self._counted_active = False
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
async def _await_any(awaitable: Any) -> Any:
|
|
423
|
+
return await awaitable
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
async def _invoke_async(
|
|
427
|
+
function: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
428
|
+
) -> Any:
|
|
429
|
+
return await function(*args, **kwargs)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
async def _invoke_background(
|
|
433
|
+
runtime: "_Runtime",
|
|
434
|
+
function: Callable[..., Any],
|
|
435
|
+
args: tuple[Any, ...],
|
|
436
|
+
kwargs: dict[str, Any],
|
|
437
|
+
) -> Any:
|
|
438
|
+
result = await _BackgroundCall(runtime, function, args, kwargs)
|
|
439
|
+
# Support sync wrappers that return an awaitable.
|
|
440
|
+
if inspect.isawaitable(result):
|
|
441
|
+
return await result
|
|
442
|
+
return result
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
async def _delay_coroutine(runtime: "_Runtime", delay: float) -> None:
|
|
446
|
+
await _Sleep(runtime, delay)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class Task(Generic[T]):
|
|
450
|
+
"""Result-bearing task managed by TinyTask's process-wide scheduler."""
|
|
451
|
+
|
|
452
|
+
PENDING = "pending"
|
|
453
|
+
SUCCEEDED = "succeeded"
|
|
454
|
+
FAILED = "failed"
|
|
455
|
+
CANCELLED = "cancelled"
|
|
456
|
+
|
|
457
|
+
def __init__(
|
|
458
|
+
self,
|
|
459
|
+
runtime: "_Runtime",
|
|
460
|
+
*,
|
|
461
|
+
name: str | None = None,
|
|
462
|
+
token: CancellationToken | None = None,
|
|
463
|
+
) -> None:
|
|
464
|
+
self._runtime = runtime
|
|
465
|
+
self.name = name or f"Task-{id(self):x}"
|
|
466
|
+
self._state = self.PENDING
|
|
467
|
+
self._result: Any = None
|
|
468
|
+
self._exception: BaseException | None = None
|
|
469
|
+
self._coroutine: Coroutine[Any, Any, Any] | None = None
|
|
470
|
+
self._awaiters: set[Task[Any]] = set()
|
|
471
|
+
self._waiting_on: _Operation | None = None
|
|
472
|
+
self._queued = False
|
|
473
|
+
self._running = False
|
|
474
|
+
self._next_value: Any = None
|
|
475
|
+
self._next_exception: BaseException | None = None
|
|
476
|
+
self._cancel_requested = False
|
|
477
|
+
self._completed = threading.Event()
|
|
478
|
+
self._token = token or CancellationToken.none()
|
|
479
|
+
self._token_registration: CancellationTokenRegistration | None = None
|
|
480
|
+
self._pass_token = False
|
|
481
|
+
|
|
482
|
+
@classmethod
|
|
483
|
+
def configure(
|
|
484
|
+
cls,
|
|
485
|
+
*,
|
|
486
|
+
max_workers: int | None = None,
|
|
487
|
+
max_pending_background: int | None = None,
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Configure worker capacity before the runtime is first started.
|
|
490
|
+
|
|
491
|
+
Background overload policy is intentionally explicit and currently fixed
|
|
492
|
+
to rejection: excess work fails with BackgroundQueueFull rather than
|
|
493
|
+
growing an unbounded queue or blocking the scheduler thread.
|
|
494
|
+
"""
|
|
495
|
+
global _runtime_config
|
|
496
|
+
with _runtime_lock:
|
|
497
|
+
if _runtime is not None and not _runtime.closed:
|
|
498
|
+
raise RuntimeError("Task.configure() must be called before runtime startup")
|
|
499
|
+
config = dict(_runtime_config)
|
|
500
|
+
if max_workers is not None:
|
|
501
|
+
if isinstance(max_workers, bool) or not isinstance(max_workers, int) or max_workers < 1:
|
|
502
|
+
raise ValueError("max_workers must be an integer >= 1")
|
|
503
|
+
config["max_workers"] = max_workers
|
|
504
|
+
if max_pending_background is not None:
|
|
505
|
+
if (
|
|
506
|
+
isinstance(max_pending_background, bool)
|
|
507
|
+
or not isinstance(max_pending_background, int)
|
|
508
|
+
or max_pending_background < 0
|
|
509
|
+
):
|
|
510
|
+
raise ValueError("max_pending_background must be an integer >= 0")
|
|
511
|
+
config["max_pending_background"] = max_pending_background
|
|
512
|
+
_runtime_config = config
|
|
513
|
+
|
|
514
|
+
@classmethod
|
|
515
|
+
def set_exception_handler(
|
|
516
|
+
cls, handler: Callable[[dict[str, Any]], Any] | None
|
|
517
|
+
) -> None:
|
|
518
|
+
"""Install a handler for unexpected scheduler/backend errors.
|
|
519
|
+
|
|
520
|
+
Handler receives a context mapping containing at least ``message`` and
|
|
521
|
+
``exception``. If no handler is installed, these failures are logged.
|
|
522
|
+
"""
|
|
523
|
+
if handler is not None and not callable(handler):
|
|
524
|
+
raise TypeError("handler must be callable or None")
|
|
525
|
+
_get_runtime().set_exception_handler(handler)
|
|
526
|
+
|
|
527
|
+
@classmethod
|
|
528
|
+
def run(
|
|
529
|
+
cls,
|
|
530
|
+
work: Any,
|
|
531
|
+
/,
|
|
532
|
+
*args: Any,
|
|
533
|
+
name: str | None = None,
|
|
534
|
+
token: CancellationToken | None = None,
|
|
535
|
+
pass_token: bool = False,
|
|
536
|
+
**kwargs: Any,
|
|
537
|
+
) -> "Task[Any]":
|
|
538
|
+
"""Schedule a coroutine/awaitable or ordinary callable.
|
|
539
|
+
|
|
540
|
+
Ordinary functions run in the bounded worker pool. ``token`` controls
|
|
541
|
+
the Task's cancellation. With ``pass_token=True``, that same token is
|
|
542
|
+
also supplied to the target callable as the keyword argument ``token``
|
|
543
|
+
(useful for cooperative worker functions).
|
|
544
|
+
"""
|
|
545
|
+
if inspect.isawaitable(work):
|
|
546
|
+
if args or kwargs or pass_token:
|
|
547
|
+
if inspect.iscoroutine(work):
|
|
548
|
+
work.close()
|
|
549
|
+
raise TypeError("args/kwargs/pass_token cannot be supplied with an awaitable instance")
|
|
550
|
+
elif not callable(work):
|
|
551
|
+
raise TypeError("Task.run() expects an awaitable or a callable")
|
|
552
|
+
if token is not None and not isinstance(token, CancellationToken):
|
|
553
|
+
if inspect.iscoroutine(work):
|
|
554
|
+
work.close()
|
|
555
|
+
raise TypeError("token must be a CancellationToken")
|
|
556
|
+
if pass_token and token is None:
|
|
557
|
+
if inspect.iscoroutine(work):
|
|
558
|
+
work.close()
|
|
559
|
+
raise ValueError("pass_token=True requires an explicit token")
|
|
560
|
+
runtime = _get_runtime()
|
|
561
|
+
task = cls(runtime, name=name, token=token)
|
|
562
|
+
task._pass_token = pass_token
|
|
563
|
+
runtime._submit(task, work, args, kwargs)
|
|
564
|
+
return task
|
|
565
|
+
|
|
566
|
+
@classmethod
|
|
567
|
+
def when_all(
|
|
568
|
+
cls,
|
|
569
|
+
*tasks: Any,
|
|
570
|
+
name: str | None = None,
|
|
571
|
+
token: CancellationToken | None = None,
|
|
572
|
+
) -> "Task[list[Any]]":
|
|
573
|
+
"""Complete when every supplied task completes, preserving input order.
|
|
574
|
+
|
|
575
|
+
Accepts Task instances and raw awaitables (including coroutine objects)
|
|
576
|
+
as positional arguments or in one iterable. Raw awaitables are scheduled
|
|
577
|
+
automatically. Child tasks are not cancelled if this
|
|
578
|
+
aggregate task is cancelled. A single child failure is re-raised; if
|
|
579
|
+
several children fail, TaskAggregateError exposes all their exceptions.
|
|
580
|
+
If there are no tasks, the result is an empty list.
|
|
581
|
+
"""
|
|
582
|
+
if token is not None and not isinstance(token, CancellationToken):
|
|
583
|
+
raise TypeError("token must be a CancellationToken")
|
|
584
|
+
normalized = _normalize_tasks(tasks, allow_empty=True)
|
|
585
|
+
return cast(
|
|
586
|
+
"Task[list[Any]]",
|
|
587
|
+
cls.run(_when_all_coroutine, normalized, name=name, token=token),
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
@classmethod
|
|
591
|
+
def when_any(
|
|
592
|
+
cls,
|
|
593
|
+
*tasks: Any,
|
|
594
|
+
name: str | None = None,
|
|
595
|
+
token: CancellationToken | None = None,
|
|
596
|
+
) -> "Task[Task[Any]]":
|
|
597
|
+
"""Complete when one supplied task completes and return that task.
|
|
598
|
+
|
|
599
|
+
Accepts Task instances and raw awaitables (including coroutine objects)
|
|
600
|
+
positionally or in one iterable. Raw awaitables are scheduled
|
|
601
|
+
automatically. The winning task is returned even if it failed or was
|
|
602
|
+
cancelled; inspect/await that task to observe
|
|
603
|
+
its outcome. Losing tasks continue running. An empty input is invalid.
|
|
604
|
+
If multiple tasks are already complete at observation time, input order
|
|
605
|
+
breaks the tie deterministically.
|
|
606
|
+
"""
|
|
607
|
+
if token is not None and not isinstance(token, CancellationToken):
|
|
608
|
+
raise TypeError("token must be a CancellationToken")
|
|
609
|
+
normalized = _normalize_tasks(tasks, allow_empty=False)
|
|
610
|
+
return cast(
|
|
611
|
+
"Task[Task[Any]]",
|
|
612
|
+
cls.run(_when_any_coroutine, normalized, name=name, token=token),
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
@classmethod
|
|
616
|
+
def delay(
|
|
617
|
+
cls,
|
|
618
|
+
seconds: float,
|
|
619
|
+
*,
|
|
620
|
+
token: CancellationToken | None = None,
|
|
621
|
+
name: str | None = None,
|
|
622
|
+
) -> "Task[None]":
|
|
623
|
+
"""Return a task completed after seconds without blocking a worker."""
|
|
624
|
+
if seconds < 0:
|
|
625
|
+
raise ValueError("delay must be >= 0")
|
|
626
|
+
if token is not None and not isinstance(token, CancellationToken):
|
|
627
|
+
raise TypeError("token must be a CancellationToken")
|
|
628
|
+
runtime = _get_runtime()
|
|
629
|
+
task: Task[None] = cls(runtime, name=name or f"Delay({seconds:g}s)", token=token)
|
|
630
|
+
# During graceful cancellation, allow an already-running coroutine to
|
|
631
|
+
# create a delay for async cleanup. External callers are still rejected.
|
|
632
|
+
runtime._submit_coroutine(
|
|
633
|
+
task,
|
|
634
|
+
_delay_coroutine(runtime, seconds),
|
|
635
|
+
allow_during_shutdown=runtime.is_runtime_thread(),
|
|
636
|
+
)
|
|
637
|
+
return task
|
|
638
|
+
|
|
639
|
+
@classmethod
|
|
640
|
+
def yield_now(cls) -> _YieldNow:
|
|
641
|
+
"""Awaitable that lets other ready tasks run."""
|
|
642
|
+
return _YieldNow(_get_runtime())
|
|
643
|
+
|
|
644
|
+
@classmethod
|
|
645
|
+
def shutdown(
|
|
646
|
+
cls,
|
|
647
|
+
*,
|
|
648
|
+
grace_timeout: float = 1.0,
|
|
649
|
+
worker_timeout: float = 2.0,
|
|
650
|
+
) -> ShutdownReport:
|
|
651
|
+
"""Stop the singleton runtime and report work that could not be stopped.
|
|
652
|
+
|
|
653
|
+
Coroutines receive cancellation and get up to ``grace_timeout`` to unwind.
|
|
654
|
+
Running synchronous worker functions are allowed up to ``worker_timeout``
|
|
655
|
+
to finish; Python cannot forcibly stop them. Pending worker jobs are cancelled.
|
|
656
|
+
The singleton can be lazily recreated by later Task.run calls.
|
|
657
|
+
"""
|
|
658
|
+
if grace_timeout < 0 or worker_timeout < 0:
|
|
659
|
+
raise ValueError("shutdown timeouts must be >= 0")
|
|
660
|
+
global _runtime
|
|
661
|
+
with _runtime_lock:
|
|
662
|
+
runtime = _runtime
|
|
663
|
+
if runtime is None:
|
|
664
|
+
return ShutdownReport(True, 0, 0, False)
|
|
665
|
+
report = runtime.shutdown(grace_timeout=grace_timeout, worker_timeout=worker_timeout)
|
|
666
|
+
with _runtime_lock:
|
|
667
|
+
if _runtime is runtime and runtime.closed:
|
|
668
|
+
_runtime = None
|
|
669
|
+
return report
|
|
670
|
+
|
|
671
|
+
@property
|
|
672
|
+
def cancellation_token(self) -> CancellationToken:
|
|
673
|
+
return self._token
|
|
674
|
+
|
|
675
|
+
def done(self) -> bool:
|
|
676
|
+
return self._completed.is_set()
|
|
677
|
+
|
|
678
|
+
def cancelled(self) -> bool:
|
|
679
|
+
return self._completed.is_set() and self._state == self.CANCELLED
|
|
680
|
+
|
|
681
|
+
def result(self, timeout: float | None = None) -> T:
|
|
682
|
+
"""Block an external thread until completion and return/raise the result."""
|
|
683
|
+
if not self.done() and self._runtime.is_runtime_thread():
|
|
684
|
+
raise RuntimeError("Task.result() would block the runtime thread; use 'await task'")
|
|
685
|
+
if not self._completed.wait(timeout):
|
|
686
|
+
raise TimeoutError(f"{self.name} did not complete before the timeout")
|
|
687
|
+
return self._raise_or_return()
|
|
688
|
+
|
|
689
|
+
def exception(self, timeout: float | None = None) -> BaseException | None:
|
|
690
|
+
if not self.done() and self._runtime.is_runtime_thread():
|
|
691
|
+
raise RuntimeError("Task.exception() would block the runtime thread")
|
|
692
|
+
if not self._completed.wait(timeout):
|
|
693
|
+
raise TimeoutError(f"{self.name} did not complete before the timeout")
|
|
694
|
+
if self._state == self.CANCELLED:
|
|
695
|
+
raise OperationCanceledError(f"{self.name} was cancelled")
|
|
696
|
+
return self._exception
|
|
697
|
+
|
|
698
|
+
def cancel(self) -> bool:
|
|
699
|
+
"""Request cancellation of this Task, independently of other token users."""
|
|
700
|
+
if self.done():
|
|
701
|
+
return False
|
|
702
|
+
try:
|
|
703
|
+
self._runtime._post(lambda: self._cancel_on_runtime(), internal=True)
|
|
704
|
+
except RuntimeShutdownError:
|
|
705
|
+
return False
|
|
706
|
+
return True
|
|
707
|
+
|
|
708
|
+
def __await__(self):
|
|
709
|
+
if self.done():
|
|
710
|
+
return self._raise_or_return()
|
|
711
|
+
result = yield _WaitTask(self._runtime, self)
|
|
712
|
+
return result
|
|
713
|
+
|
|
714
|
+
def _raise_or_return(self) -> T:
|
|
715
|
+
if self._state == self.CANCELLED:
|
|
716
|
+
raise OperationCanceledError(f"{self.name} was cancelled")
|
|
717
|
+
if self._state == self.FAILED:
|
|
718
|
+
assert self._exception is not None
|
|
719
|
+
raise self._exception
|
|
720
|
+
if self._state != self.SUCCEEDED:
|
|
721
|
+
raise InvalidStateError(f"{self.name} has not completed")
|
|
722
|
+
return cast(T, self._result)
|
|
723
|
+
|
|
724
|
+
def _bind_token(self) -> None:
|
|
725
|
+
if not self._token.can_be_cancelled or self._token_registration is not None:
|
|
726
|
+
return
|
|
727
|
+
self._token_registration = self._token.register(self.cancel)
|
|
728
|
+
|
|
729
|
+
def _cancel_on_runtime(self) -> None:
|
|
730
|
+
if self.done():
|
|
731
|
+
return
|
|
732
|
+
self._cancel_requested = True
|
|
733
|
+
self._runtime._detach_wait(self)
|
|
734
|
+
if not self._running:
|
|
735
|
+
self._runtime._schedule(self, exc=OperationCanceledError(f"{self.name} cancelled"))
|
|
736
|
+
|
|
737
|
+
def _finish(
|
|
738
|
+
self,
|
|
739
|
+
state: str,
|
|
740
|
+
result: Any = None,
|
|
741
|
+
exception: BaseException | None = None,
|
|
742
|
+
) -> None:
|
|
743
|
+
if self.done():
|
|
744
|
+
return
|
|
745
|
+
self._state = state
|
|
746
|
+
self._result = result
|
|
747
|
+
self._exception = exception
|
|
748
|
+
registration = self._token_registration
|
|
749
|
+
self._token_registration = None
|
|
750
|
+
if registration is not None:
|
|
751
|
+
registration.dispose()
|
|
752
|
+
self._completed.set()
|
|
753
|
+
self._runtime._task_completed(self)
|
|
754
|
+
|
|
755
|
+
def _step(self) -> None:
|
|
756
|
+
if self.done():
|
|
757
|
+
return
|
|
758
|
+
coroutine = self._coroutine
|
|
759
|
+
if coroutine is None:
|
|
760
|
+
self._finish(self.FAILED, exception=RuntimeError("task has no coroutine"))
|
|
761
|
+
return
|
|
762
|
+
value = self._next_value
|
|
763
|
+
exc = self._next_exception
|
|
764
|
+
self._next_value = None
|
|
765
|
+
self._next_exception = None
|
|
766
|
+
self._running = True
|
|
767
|
+
previous_task = self._runtime._current_task
|
|
768
|
+
self._runtime._current_task = self
|
|
769
|
+
if isinstance(exc, OperationCanceledError):
|
|
770
|
+
self._cancel_requested = False
|
|
771
|
+
try:
|
|
772
|
+
try:
|
|
773
|
+
if exc is not None:
|
|
774
|
+
yielded = coroutine.throw(exc)
|
|
775
|
+
else:
|
|
776
|
+
yielded = coroutine.send(value)
|
|
777
|
+
except StopIteration as stop:
|
|
778
|
+
self._finish(self.SUCCEEDED, result=stop.value)
|
|
779
|
+
return
|
|
780
|
+
except OperationCanceledError as cancelled:
|
|
781
|
+
self._finish(self.CANCELLED, exception=cancelled)
|
|
782
|
+
return
|
|
783
|
+
except BaseException as error:
|
|
784
|
+
self._finish(self.FAILED, exception=error)
|
|
785
|
+
return
|
|
786
|
+
finally:
|
|
787
|
+
self._running = False
|
|
788
|
+
self._runtime._current_task = previous_task
|
|
789
|
+
if self._cancel_requested:
|
|
790
|
+
self._runtime._schedule(self, exc=OperationCanceledError(f"{self.name} cancelled"))
|
|
791
|
+
return
|
|
792
|
+
try:
|
|
793
|
+
self._runtime._handle_yielded(self, yielded)
|
|
794
|
+
except BaseException as error:
|
|
795
|
+
self._runtime._detach_wait(self)
|
|
796
|
+
self._runtime._schedule(self, exc=error)
|
|
797
|
+
|
|
798
|
+
def __repr__(self) -> str:
|
|
799
|
+
return f"<{self.name} state={self._state}>"
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _normalize_tasks(items: tuple[Any, ...], *, allow_empty: bool) -> tuple[Task[Any], ...]:
|
|
803
|
+
"""Normalize task-combinator inputs, scheduling raw awaitables as Tasks.
|
|
804
|
+
|
|
805
|
+
Accepts Task instances, coroutine objects, and other awaitables supported by
|
|
806
|
+
this runtime. A single iterable is also accepted. If validation fails,
|
|
807
|
+
coroutine objects supplied to this call are closed so Python does not emit
|
|
808
|
+
"coroutine was never awaited" warnings for rejected inputs.
|
|
809
|
+
"""
|
|
810
|
+
# A single awaitable is one operation, not an iterable of operations.
|
|
811
|
+
if (
|
|
812
|
+
len(items) == 1
|
|
813
|
+
and not isinstance(items[0], Task)
|
|
814
|
+
and not inspect.isawaitable(items[0])
|
|
815
|
+
and not isinstance(items[0], (str, bytes, bytearray))
|
|
816
|
+
):
|
|
817
|
+
candidate = items[0]
|
|
818
|
+
try:
|
|
819
|
+
items = tuple(candidate)
|
|
820
|
+
except TypeError:
|
|
821
|
+
pass
|
|
822
|
+
|
|
823
|
+
if not items and not allow_empty:
|
|
824
|
+
raise ValueError("Task.when_any() requires at least one task or awaitable")
|
|
825
|
+
|
|
826
|
+
def close_raw_coroutines(values: tuple[Any, ...]) -> None:
|
|
827
|
+
seen: set[int] = set()
|
|
828
|
+
for value in values:
|
|
829
|
+
if inspect.iscoroutine(value) and id(value) not in seen:
|
|
830
|
+
seen.add(id(value))
|
|
831
|
+
try:
|
|
832
|
+
value.close()
|
|
833
|
+
except BaseException:
|
|
834
|
+
_LOG.exception("Failed to close rejected coroutine supplied to Task.when_all/when_any")
|
|
835
|
+
|
|
836
|
+
# Validate everything before scheduling anything, avoiding partial starts
|
|
837
|
+
# when the caller accidentally mixes a coroutine with a non-awaitable.
|
|
838
|
+
invalid = [item for item in items if not isinstance(item, Task) and not inspect.isawaitable(item)]
|
|
839
|
+
if invalid:
|
|
840
|
+
close_raw_coroutines(items)
|
|
841
|
+
raise TypeError(
|
|
842
|
+
"Task.when_all/when_any accepts Task instances or awaitables; "
|
|
843
|
+
f"got {type(invalid[0]).__name__}"
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
# The same one-shot coroutine/awaitable cannot safely be scheduled twice.
|
|
847
|
+
seen_awaitables: set[int] = set()
|
|
848
|
+
for item in items:
|
|
849
|
+
if isinstance(item, Task):
|
|
850
|
+
continue
|
|
851
|
+
identity = id(item)
|
|
852
|
+
if identity in seen_awaitables:
|
|
853
|
+
close_raw_coroutines(items)
|
|
854
|
+
raise ValueError("the same awaitable instance was supplied more than once")
|
|
855
|
+
seen_awaitables.add(identity)
|
|
856
|
+
|
|
857
|
+
normalized: list[Task[Any]] = []
|
|
858
|
+
index = 0
|
|
859
|
+
try:
|
|
860
|
+
for index, item in enumerate(items):
|
|
861
|
+
if isinstance(item, Task):
|
|
862
|
+
normalized.append(item)
|
|
863
|
+
else:
|
|
864
|
+
# Task.run takes ownership of the awaitable and schedules it.
|
|
865
|
+
normalized.append(Task.run(item))
|
|
866
|
+
except BaseException:
|
|
867
|
+
# The failed Task.run closes its own coroutine when possible. Close any
|
|
868
|
+
# later coroutine inputs which have not been handed to the runtime.
|
|
869
|
+
close_raw_coroutines(tuple(items[index + 1 :]))
|
|
870
|
+
raise
|
|
871
|
+
return tuple(normalized)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
async def _when_all_coroutine(tasks: tuple[Task[Any], ...]) -> list[Any]:
|
|
875
|
+
await _WaitMany(_get_runtime(), tasks, "all")
|
|
876
|
+
failures = tuple(task._exception for task in tasks if task._state == Task.FAILED)
|
|
877
|
+
if len(failures) == 1:
|
|
878
|
+
assert failures[0] is not None
|
|
879
|
+
raise failures[0]
|
|
880
|
+
if len(failures) > 1:
|
|
881
|
+
raise AggregateError(cast(tuple[Exception, ...], failures))
|
|
882
|
+
if any(task._state == Task.CANCELLED for task in tasks):
|
|
883
|
+
raise OperationCanceledError("One or more tasks passed to Task.when_all() were cancelled")
|
|
884
|
+
return [task._result for task in tasks]
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
async def _when_any_coroutine(tasks: tuple[Task[Any], ...]) -> Task[Any]:
|
|
888
|
+
winner = await _WaitMany(_get_runtime(), tasks, "any")
|
|
889
|
+
return cast(Task[Any], winner)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
class IO:
|
|
893
|
+
"""Selector-backed awaitables for nonblocking sockets."""
|
|
894
|
+
|
|
895
|
+
@staticmethod
|
|
896
|
+
def wait_readable(
|
|
897
|
+
sock: socket.socket, *, token: CancellationToken | None = None
|
|
898
|
+
) -> _WaitIO:
|
|
899
|
+
if token is not None and not isinstance(token, CancellationToken):
|
|
900
|
+
raise TypeError("token must be a CancellationToken")
|
|
901
|
+
return _WaitIO(_get_runtime(), sock, "read", token)
|
|
902
|
+
|
|
903
|
+
@staticmethod
|
|
904
|
+
def wait_writable(
|
|
905
|
+
sock: socket.socket, *, token: CancellationToken | None = None
|
|
906
|
+
) -> _WaitIO:
|
|
907
|
+
if token is not None and not isinstance(token, CancellationToken):
|
|
908
|
+
raise TypeError("token must be a CancellationToken")
|
|
909
|
+
return _WaitIO(_get_runtime(), sock, "write", token)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
class _Runtime:
|
|
913
|
+
"""Private singleton scheduler: ready queue + timers + selector + workers."""
|
|
914
|
+
|
|
915
|
+
def __init__(self, *, max_workers: int, max_pending_background: int) -> None:
|
|
916
|
+
self._selector = selectors.DefaultSelector()
|
|
917
|
+
self._wake_reader, self._wake_writer = socket.socketpair()
|
|
918
|
+
self._wake_reader.setblocking(False)
|
|
919
|
+
self._wake_writer.setblocking(False)
|
|
920
|
+
self._selector.register(self._wake_reader, selectors.EVENT_READ, data=_WAKE)
|
|
921
|
+
self._ready: deque[Task[Any]] = deque()
|
|
922
|
+
self._tasks: set[Task[Any]] = set()
|
|
923
|
+
self._timers: list[tuple[float, int, Task[Any], _Sleep]] = []
|
|
924
|
+
self._timer_sequence = count()
|
|
925
|
+
self._io_waiters: dict[socket.socket, dict[str, set[Task[Any]]]] = {}
|
|
926
|
+
self._commands: queue.SimpleQueue[Callable[[], None]] = queue.SimpleQueue()
|
|
927
|
+
self._max_workers = max_workers
|
|
928
|
+
self._max_pending_background = max_pending_background
|
|
929
|
+
self._pending_background: deque[tuple[Task[Any], _BackgroundCall]] = deque()
|
|
930
|
+
self._background_active = 0 # scheduler-thread-owned submitted call count
|
|
931
|
+
self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="tinytask-io")
|
|
932
|
+
self._inflight_lock = threading.Condition()
|
|
933
|
+
self._inflight_futures: set[Future[Any]] = set()
|
|
934
|
+
self._current_task: Task[Any] | None = None
|
|
935
|
+
self._thread_id: int | None = None
|
|
936
|
+
self._start_lock = threading.Lock()
|
|
937
|
+
self._started = threading.Event()
|
|
938
|
+
self._state_lock = threading.RLock()
|
|
939
|
+
self._shutdown_requested = False
|
|
940
|
+
self._shutdown_deadline: float | None = None
|
|
941
|
+
self._stopping = False
|
|
942
|
+
self._closed = False
|
|
943
|
+
self._shutdown_tasks_cancelled = 0
|
|
944
|
+
self._thread: threading.Thread | None = None
|
|
945
|
+
self._exception_handler: Callable[[dict[str, Any]], Any] | None = None
|
|
946
|
+
|
|
947
|
+
@property
|
|
948
|
+
def closed(self) -> bool:
|
|
949
|
+
with self._state_lock:
|
|
950
|
+
return self._closed
|
|
951
|
+
|
|
952
|
+
def _ensure_started(self) -> None:
|
|
953
|
+
if self._started.is_set():
|
|
954
|
+
return
|
|
955
|
+
with self._start_lock:
|
|
956
|
+
if self._started.is_set():
|
|
957
|
+
return
|
|
958
|
+
with self._state_lock:
|
|
959
|
+
if self._closed or self._shutdown_requested:
|
|
960
|
+
raise RuntimeShutdownError("TinyTask runtime is shutting down")
|
|
961
|
+
self._thread = threading.Thread(target=self._loop, name="TinyTask-runtime", daemon=True)
|
|
962
|
+
self._thread.start()
|
|
963
|
+
self._started.wait()
|
|
964
|
+
|
|
965
|
+
def is_runtime_thread(self) -> bool:
|
|
966
|
+
return self._thread_id == threading.get_ident()
|
|
967
|
+
|
|
968
|
+
def set_exception_handler(self, handler: Callable[[dict[str, Any]], Any] | None) -> None:
|
|
969
|
+
self._exception_handler = handler
|
|
970
|
+
|
|
971
|
+
def _report_exception(self, context: dict[str, Any], error: BaseException) -> None:
|
|
972
|
+
handler = self._exception_handler
|
|
973
|
+
if handler is not None:
|
|
974
|
+
try:
|
|
975
|
+
handler({**context, "exception": error})
|
|
976
|
+
return
|
|
977
|
+
except Exception:
|
|
978
|
+
_LOG.exception("TinyTask exception handler itself failed")
|
|
979
|
+
message = str(context.get("message", "Unexpected TinyTask runtime error"))
|
|
980
|
+
_LOG.error("%s", message, exc_info=(type(error), error, error.__traceback__))
|
|
981
|
+
|
|
982
|
+
def _post(self, callback: Callable[[], None], *, internal: bool = False) -> None:
|
|
983
|
+
self._ensure_started()
|
|
984
|
+
execute_inline = False
|
|
985
|
+
with self._state_lock:
|
|
986
|
+
if self._closed or self._stopping or (self._shutdown_requested and not internal):
|
|
987
|
+
raise RuntimeShutdownError("TinyTask runtime is shutting down")
|
|
988
|
+
if self.is_runtime_thread():
|
|
989
|
+
execute_inline = True
|
|
990
|
+
else:
|
|
991
|
+
self._commands.put(callback)
|
|
992
|
+
if execute_inline:
|
|
993
|
+
callback()
|
|
994
|
+
return
|
|
995
|
+
try:
|
|
996
|
+
self._wake_writer.send(b"\x00")
|
|
997
|
+
except (BlockingIOError, OSError):
|
|
998
|
+
# A full wake-up socket already guarantees selector readability.
|
|
999
|
+
pass
|
|
1000
|
+
|
|
1001
|
+
def _submit(self, task: Task[Any], work: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
|
1002
|
+
try:
|
|
1003
|
+
self._post(lambda: self._start_target(task, work, args, kwargs))
|
|
1004
|
+
except BaseException:
|
|
1005
|
+
if inspect.iscoroutine(work):
|
|
1006
|
+
work.close()
|
|
1007
|
+
raise
|
|
1008
|
+
|
|
1009
|
+
def _submit_coroutine(
|
|
1010
|
+
self,
|
|
1011
|
+
task: Task[Any],
|
|
1012
|
+
coroutine: Coroutine[Any, Any, Any],
|
|
1013
|
+
*,
|
|
1014
|
+
allow_during_shutdown: bool = False,
|
|
1015
|
+
) -> None:
|
|
1016
|
+
try:
|
|
1017
|
+
self._post(
|
|
1018
|
+
lambda: self._start_coroutine(task, coroutine),
|
|
1019
|
+
internal=allow_during_shutdown,
|
|
1020
|
+
)
|
|
1021
|
+
except BaseException:
|
|
1022
|
+
coroutine.close()
|
|
1023
|
+
raise
|
|
1024
|
+
|
|
1025
|
+
def _start_target(
|
|
1026
|
+
self, task: Task[Any], work: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
1027
|
+
) -> None:
|
|
1028
|
+
if task.done():
|
|
1029
|
+
if inspect.iscoroutine(work):
|
|
1030
|
+
work.close()
|
|
1031
|
+
return
|
|
1032
|
+
if task._token.is_cancellation_requested:
|
|
1033
|
+
if inspect.iscoroutine(work):
|
|
1034
|
+
work.close()
|
|
1035
|
+
task._finish(Task.CANCELLED, exception=OperationCanceledError(f"{task.name} cancelled before start"))
|
|
1036
|
+
return
|
|
1037
|
+
try:
|
|
1038
|
+
call_kwargs = dict(kwargs)
|
|
1039
|
+
if task._pass_token:
|
|
1040
|
+
call_kwargs["token"] = task._token
|
|
1041
|
+
if inspect.isawaitable(work):
|
|
1042
|
+
coroutine = work if inspect.iscoroutine(work) else _await_any(work)
|
|
1043
|
+
elif _is_async_callable(work):
|
|
1044
|
+
coroutine = _invoke_async(work, args, call_kwargs)
|
|
1045
|
+
else:
|
|
1046
|
+
coroutine = _invoke_background(self, work, args, call_kwargs)
|
|
1047
|
+
self._start_coroutine(task, coroutine)
|
|
1048
|
+
except BaseException as error:
|
|
1049
|
+
self._tasks.add(task)
|
|
1050
|
+
task._finish(Task.FAILED, exception=error)
|
|
1051
|
+
|
|
1052
|
+
def _start_coroutine(self, task: Task[Any], coroutine: Coroutine[Any, Any, Any]) -> None:
|
|
1053
|
+
if task.done():
|
|
1054
|
+
coroutine.close()
|
|
1055
|
+
return
|
|
1056
|
+
task._coroutine = coroutine
|
|
1057
|
+
self._tasks.add(task)
|
|
1058
|
+
task._bind_token()
|
|
1059
|
+
self._schedule(task)
|
|
1060
|
+
|
|
1061
|
+
def _schedule(
|
|
1062
|
+
self, task: Task[Any], value: Any = None, exc: BaseException | None = None
|
|
1063
|
+
) -> None:
|
|
1064
|
+
if task.done():
|
|
1065
|
+
return
|
|
1066
|
+
if task._queued:
|
|
1067
|
+
if exc is not None:
|
|
1068
|
+
task._next_exception = exc
|
|
1069
|
+
task._next_value = None
|
|
1070
|
+
elif task._next_exception is None:
|
|
1071
|
+
task._next_value = value
|
|
1072
|
+
return
|
|
1073
|
+
task._queued = True
|
|
1074
|
+
task._next_value = value
|
|
1075
|
+
task._next_exception = exc
|
|
1076
|
+
self._ready.append(task)
|
|
1077
|
+
|
|
1078
|
+
def _task_completed(self, task: Task[Any]) -> None:
|
|
1079
|
+
self._tasks.discard(task)
|
|
1080
|
+
if self._shutdown_requested and task._state == Task.CANCELLED:
|
|
1081
|
+
self._shutdown_tasks_cancelled += 1
|
|
1082
|
+
self._detach_wait(task)
|
|
1083
|
+
for waiter in list(task._awaiters):
|
|
1084
|
+
operation = waiter._waiting_on
|
|
1085
|
+
if isinstance(operation, _WaitTask) and operation.task is task:
|
|
1086
|
+
self._detach_wait(waiter)
|
|
1087
|
+
if task._state == Task.SUCCEEDED:
|
|
1088
|
+
self._schedule(waiter, value=task._result)
|
|
1089
|
+
elif task._state == Task.CANCELLED:
|
|
1090
|
+
self._schedule(waiter, exc=OperationCanceledError(f"{task.name} was cancelled"))
|
|
1091
|
+
else:
|
|
1092
|
+
assert task._exception is not None
|
|
1093
|
+
self._schedule(waiter, exc=task._exception)
|
|
1094
|
+
elif isinstance(operation, _WaitMany) and task in operation.tasks:
|
|
1095
|
+
if operation.mode == "any":
|
|
1096
|
+
self._detach_wait(waiter)
|
|
1097
|
+
self._schedule(waiter, value=task)
|
|
1098
|
+
elif all(child.done() for child in operation.tasks):
|
|
1099
|
+
self._detach_wait(waiter)
|
|
1100
|
+
self._schedule(waiter, value=operation.tasks)
|
|
1101
|
+
task._awaiters.clear()
|
|
1102
|
+
|
|
1103
|
+
def _handle_yielded(self, task: Task[Any], yielded: Any) -> None:
|
|
1104
|
+
if not isinstance(yielded, _Operation):
|
|
1105
|
+
raise RuntimeError(
|
|
1106
|
+
f"{task.name} yielded unsupported object {yielded!r}; "
|
|
1107
|
+
"this runtime only supports TinyTask awaitables"
|
|
1108
|
+
)
|
|
1109
|
+
if yielded.runtime is not self:
|
|
1110
|
+
raise RuntimeError("awaitable belongs to a different runtime")
|
|
1111
|
+
if yielded.token.is_cancellation_requested:
|
|
1112
|
+
self._schedule(task, exc=OperationCanceledError("Operation token was cancelled"))
|
|
1113
|
+
return
|
|
1114
|
+
|
|
1115
|
+
if isinstance(yielded, _YieldNow):
|
|
1116
|
+
self._schedule(task)
|
|
1117
|
+
elif isinstance(yielded, _Sleep):
|
|
1118
|
+
if yielded.delay == 0:
|
|
1119
|
+
self._schedule(task)
|
|
1120
|
+
else:
|
|
1121
|
+
task._waiting_on = yielded
|
|
1122
|
+
heapq.heappush(self._timers, (yielded.deadline, next(self._timer_sequence), task, yielded))
|
|
1123
|
+
self._register_operation_token(task, yielded)
|
|
1124
|
+
elif isinstance(yielded, _WaitIO):
|
|
1125
|
+
self._arm_io(task, yielded)
|
|
1126
|
+
self._register_operation_token(task, yielded)
|
|
1127
|
+
elif isinstance(yielded, _WaitMany):
|
|
1128
|
+
if yielded.mode == "any":
|
|
1129
|
+
# Deterministic tie-breaking for tasks already completed when
|
|
1130
|
+
# the operation is first observed: first completed in input order.
|
|
1131
|
+
winner = next((child for child in yielded.tasks if child.done()), None)
|
|
1132
|
+
if winner is not None:
|
|
1133
|
+
self._schedule(task, value=winner)
|
|
1134
|
+
return
|
|
1135
|
+
elif all(child.done() for child in yielded.tasks):
|
|
1136
|
+
self._schedule(task, value=yielded.tasks)
|
|
1137
|
+
return
|
|
1138
|
+
|
|
1139
|
+
pending = [child for child in yielded.tasks if not child.done()]
|
|
1140
|
+
if any(child._runtime is not self for child in pending):
|
|
1141
|
+
raise RuntimeError("cannot combine pending tasks from different runtimes")
|
|
1142
|
+
task._waiting_on = yielded
|
|
1143
|
+
for child in pending:
|
|
1144
|
+
child._awaiters.add(task)
|
|
1145
|
+
elif isinstance(yielded, _WaitTask):
|
|
1146
|
+
target = yielded.task
|
|
1147
|
+
if target is task:
|
|
1148
|
+
raise RuntimeError("a task cannot await itself")
|
|
1149
|
+
if target.done():
|
|
1150
|
+
if target._state == Task.SUCCEEDED:
|
|
1151
|
+
self._schedule(task, value=target._result)
|
|
1152
|
+
elif target._state == Task.CANCELLED:
|
|
1153
|
+
self._schedule(task, exc=OperationCanceledError(f"{target.name} was cancelled"))
|
|
1154
|
+
else:
|
|
1155
|
+
assert target._exception is not None
|
|
1156
|
+
self._schedule(task, exc=target._exception)
|
|
1157
|
+
else:
|
|
1158
|
+
task._waiting_on = yielded
|
|
1159
|
+
target._awaiters.add(task)
|
|
1160
|
+
self._register_operation_token(task, yielded)
|
|
1161
|
+
elif isinstance(yielded, _BackgroundCall):
|
|
1162
|
+
task._waiting_on = yielded
|
|
1163
|
+
if self._background_active < self._max_workers and not self._pending_background:
|
|
1164
|
+
self._start_background(task, yielded)
|
|
1165
|
+
elif len(self._pending_background) < self._max_pending_background:
|
|
1166
|
+
self._pending_background.append((task, yielded))
|
|
1167
|
+
self._register_operation_token(task, yielded)
|
|
1168
|
+
else:
|
|
1169
|
+
task._waiting_on = None
|
|
1170
|
+
raise BackgroundQueueFull(
|
|
1171
|
+
f"background queue is full (max_workers={self._max_workers}, "
|
|
1172
|
+
f"max_pending_background={self._max_pending_background})"
|
|
1173
|
+
)
|
|
1174
|
+
else:
|
|
1175
|
+
raise RuntimeError(f"unsupported operation type: {type(yielded).__name__}")
|
|
1176
|
+
|
|
1177
|
+
def _register_operation_token(self, task: Task[Any], operation: _Operation) -> None:
|
|
1178
|
+
token = operation.token
|
|
1179
|
+
if not token.can_be_cancelled:
|
|
1180
|
+
return
|
|
1181
|
+
|
|
1182
|
+
def on_cancel() -> None:
|
|
1183
|
+
try:
|
|
1184
|
+
self._post(lambda: self._cancel_operation_wait(task, operation), internal=True)
|
|
1185
|
+
except RuntimeShutdownError:
|
|
1186
|
+
pass
|
|
1187
|
+
|
|
1188
|
+
operation._token_registration = token.register(on_cancel)
|
|
1189
|
+
|
|
1190
|
+
def _dispose_operation_token(self, operation: _Operation) -> None:
|
|
1191
|
+
registration = operation._token_registration
|
|
1192
|
+
operation._token_registration = None
|
|
1193
|
+
if registration is not None:
|
|
1194
|
+
registration.dispose()
|
|
1195
|
+
|
|
1196
|
+
def _cancel_operation_wait(self, task: Task[Any], operation: _Operation) -> None:
|
|
1197
|
+
if task.done() or task._waiting_on is not operation:
|
|
1198
|
+
return
|
|
1199
|
+
self._detach_wait(task)
|
|
1200
|
+
self._schedule(task, exc=OperationCanceledError("Operation token was cancelled"))
|
|
1201
|
+
|
|
1202
|
+
def _start_background(self, task: Task[Any], operation: _BackgroundCall) -> None:
|
|
1203
|
+
if task.done() or task._waiting_on is not operation or self._shutdown_requested:
|
|
1204
|
+
return
|
|
1205
|
+
self._background_active += 1
|
|
1206
|
+
operation._counted_active = True
|
|
1207
|
+
try:
|
|
1208
|
+
future = self._executor.submit(operation.function, *operation.args, **operation.kwargs)
|
|
1209
|
+
except BaseException as error:
|
|
1210
|
+
self._background_active -= 1
|
|
1211
|
+
operation._counted_active = False
|
|
1212
|
+
task._waiting_on = None
|
|
1213
|
+
self._dispose_operation_token(operation)
|
|
1214
|
+
self._schedule(task, exc=error)
|
|
1215
|
+
self._pump_background_queue()
|
|
1216
|
+
return
|
|
1217
|
+
operation.future = future
|
|
1218
|
+
with self._inflight_lock:
|
|
1219
|
+
self._inflight_futures.add(future)
|
|
1220
|
+
future.add_done_callback(
|
|
1221
|
+
lambda completed, task=task, op=operation: self._background_worker_done(task, op, completed)
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
def _background_worker_done(
|
|
1225
|
+
self, task: Task[Any], operation: _BackgroundCall, future: Future[Any]
|
|
1226
|
+
) -> None:
|
|
1227
|
+
with self._inflight_lock:
|
|
1228
|
+
self._inflight_futures.discard(future)
|
|
1229
|
+
self._inflight_lock.notify_all()
|
|
1230
|
+
try:
|
|
1231
|
+
self._post(lambda: self._background_finished(task, operation, future), internal=True)
|
|
1232
|
+
except RuntimeShutdownError:
|
|
1233
|
+
# The future's terminal state is still recorded in _inflight_futures;
|
|
1234
|
+
# after shutdown there is no scheduler left to resume a task.
|
|
1235
|
+
pass
|
|
1236
|
+
|
|
1237
|
+
def _background_finished(
|
|
1238
|
+
self, task: Task[Any], operation: _BackgroundCall, future: Future[Any]
|
|
1239
|
+
) -> None:
|
|
1240
|
+
if operation._counted_active:
|
|
1241
|
+
operation._counted_active = False
|
|
1242
|
+
self._background_active = max(0, self._background_active - 1)
|
|
1243
|
+
if not task.done() and task._waiting_on is operation:
|
|
1244
|
+
task._waiting_on = None
|
|
1245
|
+
self._dispose_operation_token(operation)
|
|
1246
|
+
try:
|
|
1247
|
+
value = future.result()
|
|
1248
|
+
except BaseException as error:
|
|
1249
|
+
self._schedule(task, exc=error)
|
|
1250
|
+
else:
|
|
1251
|
+
self._schedule(task, value=value)
|
|
1252
|
+
self._pump_background_queue()
|
|
1253
|
+
|
|
1254
|
+
def _pump_background_queue(self) -> None:
|
|
1255
|
+
if self._shutdown_requested:
|
|
1256
|
+
return
|
|
1257
|
+
while self._background_active < self._max_workers and self._pending_background:
|
|
1258
|
+
task, operation = self._pending_background.popleft()
|
|
1259
|
+
if task.done() or task._waiting_on is not operation:
|
|
1260
|
+
continue
|
|
1261
|
+
self._dispose_operation_token(operation)
|
|
1262
|
+
self._start_background(task, operation)
|
|
1263
|
+
|
|
1264
|
+
def _arm_io(self, task: Task[Any], operation: _WaitIO) -> None:
|
|
1265
|
+
if operation.sock.fileno() < 0:
|
|
1266
|
+
raise ValueError("cannot wait on a closed socket")
|
|
1267
|
+
task._waiting_on = operation
|
|
1268
|
+
sides = self._io_waiters.setdefault(operation.sock, {"read": set(), "write": set()})
|
|
1269
|
+
sides[operation.mode].add(task)
|
|
1270
|
+
self._refresh_socket(operation.sock)
|
|
1271
|
+
|
|
1272
|
+
def _refresh_socket(self, sock: socket.socket) -> None:
|
|
1273
|
+
sides = self._io_waiters.get(sock)
|
|
1274
|
+
mask = 0
|
|
1275
|
+
if sides:
|
|
1276
|
+
if sides["read"]:
|
|
1277
|
+
mask |= selectors.EVENT_READ
|
|
1278
|
+
if sides["write"]:
|
|
1279
|
+
mask |= selectors.EVENT_WRITE
|
|
1280
|
+
try:
|
|
1281
|
+
self._selector.get_key(sock)
|
|
1282
|
+
registered = True
|
|
1283
|
+
except (KeyError, ValueError, OSError):
|
|
1284
|
+
registered = False
|
|
1285
|
+
try:
|
|
1286
|
+
if mask:
|
|
1287
|
+
if registered:
|
|
1288
|
+
self._selector.modify(sock, mask, data=None)
|
|
1289
|
+
else:
|
|
1290
|
+
self._selector.register(sock, mask, data=None)
|
|
1291
|
+
else:
|
|
1292
|
+
if registered:
|
|
1293
|
+
try:
|
|
1294
|
+
self._selector.unregister(sock)
|
|
1295
|
+
except (KeyError, ValueError, OSError):
|
|
1296
|
+
pass
|
|
1297
|
+
self._io_waiters.pop(sock, None)
|
|
1298
|
+
except (KeyError, ValueError, OSError) as error:
|
|
1299
|
+
# A socket may be closed while tasks are waiting on it. Fail those
|
|
1300
|
+
# waits instead of allowing a selector error to kill the scheduler.
|
|
1301
|
+
sides = self._io_waiters.pop(sock, None) or {"read": set(), "write": set()}
|
|
1302
|
+
affected = set(sides["read"]) | set(sides["write"])
|
|
1303
|
+
for task in affected:
|
|
1304
|
+
operation = task._waiting_on
|
|
1305
|
+
if isinstance(operation, _WaitIO) and operation.sock is sock:
|
|
1306
|
+
task._waiting_on = None
|
|
1307
|
+
self._dispose_operation_token(operation)
|
|
1308
|
+
self._schedule(task, exc=error)
|
|
1309
|
+
try:
|
|
1310
|
+
self._selector.unregister(sock)
|
|
1311
|
+
except (KeyError, ValueError, OSError):
|
|
1312
|
+
pass
|
|
1313
|
+
|
|
1314
|
+
def _detach_wait(self, task: Task[Any]) -> None:
|
|
1315
|
+
operation = task._waiting_on
|
|
1316
|
+
if operation is None:
|
|
1317
|
+
return
|
|
1318
|
+
task._waiting_on = None
|
|
1319
|
+
self._dispose_operation_token(operation)
|
|
1320
|
+
if isinstance(operation, _WaitIO):
|
|
1321
|
+
sides = self._io_waiters.get(operation.sock)
|
|
1322
|
+
if sides is not None:
|
|
1323
|
+
sides[operation.mode].discard(task)
|
|
1324
|
+
self._refresh_socket(operation.sock)
|
|
1325
|
+
elif isinstance(operation, _WaitTask):
|
|
1326
|
+
operation.task._awaiters.discard(task)
|
|
1327
|
+
elif isinstance(operation, _WaitMany):
|
|
1328
|
+
for target in operation.tasks:
|
|
1329
|
+
target._awaiters.discard(task)
|
|
1330
|
+
elif isinstance(operation, _BackgroundCall):
|
|
1331
|
+
if operation.future is None:
|
|
1332
|
+
try:
|
|
1333
|
+
self._pending_background.remove((task, operation))
|
|
1334
|
+
except ValueError:
|
|
1335
|
+
pass
|
|
1336
|
+
else:
|
|
1337
|
+
# This cancels queued executor work where possible. If already
|
|
1338
|
+
# running, the worker continues and remains tracked until it exits.
|
|
1339
|
+
operation.future.cancel()
|
|
1340
|
+
# Timer entries are lazily discarded when they reach the heap's top.
|
|
1341
|
+
|
|
1342
|
+
def _drain_wakeup(self) -> None:
|
|
1343
|
+
while True:
|
|
1344
|
+
try:
|
|
1345
|
+
if not self._wake_reader.recv(4096):
|
|
1346
|
+
break
|
|
1347
|
+
except BlockingIOError:
|
|
1348
|
+
break
|
|
1349
|
+
except OSError:
|
|
1350
|
+
break
|
|
1351
|
+
while True:
|
|
1352
|
+
try:
|
|
1353
|
+
callback = self._commands.get_nowait()
|
|
1354
|
+
except queue.Empty:
|
|
1355
|
+
break
|
|
1356
|
+
try:
|
|
1357
|
+
callback()
|
|
1358
|
+
except BaseException as error:
|
|
1359
|
+
self._report_exception(
|
|
1360
|
+
{"message": "Unhandled exception in TinyTask scheduler callback", "callback": callback},
|
|
1361
|
+
error,
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
def _process_events(self, events: list[tuple[selectors.SelectorKey, int]]) -> None:
|
|
1365
|
+
for key, mask in events:
|
|
1366
|
+
if key.data is _WAKE:
|
|
1367
|
+
self._drain_wakeup()
|
|
1368
|
+
continue
|
|
1369
|
+
sock: Any = key.fileobj
|
|
1370
|
+
sides = self._io_waiters.get(sock)
|
|
1371
|
+
if sides is None:
|
|
1372
|
+
continue
|
|
1373
|
+
to_wake: set[Task[Any]] = set()
|
|
1374
|
+
if mask & selectors.EVENT_READ:
|
|
1375
|
+
to_wake.update(sides["read"])
|
|
1376
|
+
sides["read"].clear()
|
|
1377
|
+
if mask & selectors.EVENT_WRITE:
|
|
1378
|
+
to_wake.update(sides["write"])
|
|
1379
|
+
sides["write"].clear()
|
|
1380
|
+
for task in to_wake:
|
|
1381
|
+
operation = task._waiting_on
|
|
1382
|
+
if isinstance(operation, _WaitIO) and operation.sock is sock:
|
|
1383
|
+
task._waiting_on = None
|
|
1384
|
+
self._dispose_operation_token(operation)
|
|
1385
|
+
self._schedule(task)
|
|
1386
|
+
self._refresh_socket(sock)
|
|
1387
|
+
|
|
1388
|
+
def _expire_timers(self) -> None:
|
|
1389
|
+
now = time.monotonic()
|
|
1390
|
+
while self._timers and self._timers[0][0] <= now:
|
|
1391
|
+
_, _, task, operation = heapq.heappop(self._timers)
|
|
1392
|
+
if task.done() or task._waiting_on is not operation:
|
|
1393
|
+
continue
|
|
1394
|
+
task._waiting_on = None
|
|
1395
|
+
self._dispose_operation_token(operation)
|
|
1396
|
+
self._schedule(task)
|
|
1397
|
+
|
|
1398
|
+
def _next_timeout(self) -> float | None:
|
|
1399
|
+
while self._timers:
|
|
1400
|
+
deadline, _, task, operation = self._timers[0]
|
|
1401
|
+
if task.done() or task._waiting_on is not operation:
|
|
1402
|
+
heapq.heappop(self._timers)
|
|
1403
|
+
else:
|
|
1404
|
+
return max(0.0, deadline - time.monotonic())
|
|
1405
|
+
return None
|
|
1406
|
+
|
|
1407
|
+
def _begin_shutdown(self) -> None:
|
|
1408
|
+
self._shutdown_deadline = time.monotonic() + self._shutdown_grace
|
|
1409
|
+
for task in list(self._tasks):
|
|
1410
|
+
task._cancel_on_runtime()
|
|
1411
|
+
# Anything waiting in our bounded background queue is removed as its
|
|
1412
|
+
# task receives cancellation. Do not admit more background work at shutdown.
|
|
1413
|
+
|
|
1414
|
+
def _loop(self) -> None:
|
|
1415
|
+
self._thread_id = threading.get_ident()
|
|
1416
|
+
self._started.set()
|
|
1417
|
+
try:
|
|
1418
|
+
while not self._stopping:
|
|
1419
|
+
self._drain_wakeup()
|
|
1420
|
+
if self._shutdown_requested:
|
|
1421
|
+
if not self._tasks:
|
|
1422
|
+
self._stopping = True
|
|
1423
|
+
elif self._shutdown_deadline is not None and time.monotonic() >= self._shutdown_deadline:
|
|
1424
|
+
self._stopping = True
|
|
1425
|
+
while self._ready and not self._stopping:
|
|
1426
|
+
task = self._ready.popleft()
|
|
1427
|
+
task._queued = False
|
|
1428
|
+
task._step()
|
|
1429
|
+
self._drain_wakeup()
|
|
1430
|
+
if self._shutdown_requested:
|
|
1431
|
+
if not self._tasks:
|
|
1432
|
+
self._stopping = True
|
|
1433
|
+
elif self._shutdown_deadline is not None and time.monotonic() >= self._shutdown_deadline:
|
|
1434
|
+
self._stopping = True
|
|
1435
|
+
self._expire_timers()
|
|
1436
|
+
if self._stopping:
|
|
1437
|
+
break
|
|
1438
|
+
if self._ready:
|
|
1439
|
+
continue
|
|
1440
|
+
try:
|
|
1441
|
+
events = self._selector.select(self._next_timeout())
|
|
1442
|
+
except (OSError, ValueError) as error:
|
|
1443
|
+
if self._stopping or self.closed:
|
|
1444
|
+
break
|
|
1445
|
+
self._report_exception(
|
|
1446
|
+
{"message": "Selector failed; stopping TinyTask runtime"}, error
|
|
1447
|
+
)
|
|
1448
|
+
self._stopping = True
|
|
1449
|
+
break
|
|
1450
|
+
try:
|
|
1451
|
+
self._process_events(events)
|
|
1452
|
+
except BaseException as error:
|
|
1453
|
+
self._report_exception(
|
|
1454
|
+
{"message": "Unhandled error while processing selector events"}, error
|
|
1455
|
+
)
|
|
1456
|
+
self._stopping = True
|
|
1457
|
+
except BaseException as error:
|
|
1458
|
+
self._report_exception({"message": "Fatal error in TinyTask scheduler loop"}, error)
|
|
1459
|
+
self._stopping = True
|
|
1460
|
+
finally:
|
|
1461
|
+
# Stop accepting work before releasing scheduler resources. The
|
|
1462
|
+
# closed flag is published after cleanup, so new runtimes do not
|
|
1463
|
+
# overlap this runtime's resource finalization.
|
|
1464
|
+
tasks = list(self._tasks)
|
|
1465
|
+
for task in tasks:
|
|
1466
|
+
self._detach_wait(task)
|
|
1467
|
+
for task in tasks:
|
|
1468
|
+
coroutine = task._coroutine
|
|
1469
|
+
if coroutine is not None:
|
|
1470
|
+
try:
|
|
1471
|
+
coroutine.close()
|
|
1472
|
+
except BaseException as error:
|
|
1473
|
+
self._report_exception(
|
|
1474
|
+
{"message": f"Error closing coroutine for {task.name}"}, error
|
|
1475
|
+
)
|
|
1476
|
+
task._finish(Task.CANCELLED, exception=OperationCanceledError("runtime shutting down"))
|
|
1477
|
+
# Remove scheduler-owned pending calls; they have never entered a worker.
|
|
1478
|
+
self._pending_background.clear()
|
|
1479
|
+
try:
|
|
1480
|
+
self._selector.close()
|
|
1481
|
+
except BaseException as error:
|
|
1482
|
+
self._report_exception({"message": "Error closing selector"}, error)
|
|
1483
|
+
for wake_socket in (self._wake_reader, self._wake_writer):
|
|
1484
|
+
try:
|
|
1485
|
+
wake_socket.close()
|
|
1486
|
+
except OSError:
|
|
1487
|
+
pass
|
|
1488
|
+
with self._state_lock:
|
|
1489
|
+
self._closed = True
|
|
1490
|
+
|
|
1491
|
+
def shutdown(self, *, grace_timeout: float, worker_timeout: float) -> ShutdownReport:
|
|
1492
|
+
if self.is_runtime_thread():
|
|
1493
|
+
raise RuntimeError("Task.shutdown() cannot block from the runtime thread")
|
|
1494
|
+
with self._state_lock:
|
|
1495
|
+
if self._closed:
|
|
1496
|
+
thread = self._thread
|
|
1497
|
+
else:
|
|
1498
|
+
self._shutdown_requested = True
|
|
1499
|
+
self._shutdown_grace = grace_timeout
|
|
1500
|
+
self._commands.put(self._begin_shutdown)
|
|
1501
|
+
thread = self._thread
|
|
1502
|
+
try:
|
|
1503
|
+
self._wake_writer.send(b"\x00")
|
|
1504
|
+
except (BlockingIOError, OSError):
|
|
1505
|
+
pass
|
|
1506
|
+
if thread is not None and thread.is_alive():
|
|
1507
|
+
# Grace timeout is enforced by the loop; the small allowance lets the
|
|
1508
|
+
# loop execute its finalizer after the deadline.
|
|
1509
|
+
thread.join(timeout=grace_timeout + 1.0)
|
|
1510
|
+
runtime_stopped = thread is None or not thread.is_alive()
|
|
1511
|
+
if not runtime_stopped:
|
|
1512
|
+
self._report_exception(
|
|
1513
|
+
{"message": "TinyTask scheduler thread did not stop before timeout"},
|
|
1514
|
+
TimeoutError("scheduler shutdown timed out"),
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
# Cancel executor-queued calls. Already-running functions are not killable.
|
|
1518
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
1519
|
+
worker_deadline = time.monotonic() + worker_timeout
|
|
1520
|
+
with self._inflight_lock:
|
|
1521
|
+
while self._inflight_futures:
|
|
1522
|
+
remaining = worker_deadline - time.monotonic()
|
|
1523
|
+
if remaining <= 0:
|
|
1524
|
+
break
|
|
1525
|
+
self._inflight_lock.wait(remaining)
|
|
1526
|
+
workers_still_running = len(self._inflight_futures)
|
|
1527
|
+
if workers_still_running == 0:
|
|
1528
|
+
# It is now safe to join worker threads without an unbounded wait.
|
|
1529
|
+
self._executor.shutdown(wait=True, cancel_futures=True)
|
|
1530
|
+
return ShutdownReport(
|
|
1531
|
+
runtime_stopped=runtime_stopped,
|
|
1532
|
+
tasks_cancelled=self._shutdown_tasks_cancelled,
|
|
1533
|
+
workers_still_running=workers_still_running,
|
|
1534
|
+
timed_out=(not runtime_stopped or workers_still_running > 0),
|
|
1535
|
+
)
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def _is_async_callable(value: Any) -> bool:
|
|
1539
|
+
if inspect.iscoroutinefunction(value):
|
|
1540
|
+
return True
|
|
1541
|
+
call = getattr(value, "__call__", None)
|
|
1542
|
+
return inspect.iscoroutinefunction(call)
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
_runtime_lock = threading.RLock()
|
|
1546
|
+
_runtime: _Runtime | None = None
|
|
1547
|
+
_runtime_config: dict[str, int] = {
|
|
1548
|
+
"max_workers": min(32, (os.cpu_count() or 1) + 4),
|
|
1549
|
+
"max_pending_background": 128,
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def _get_runtime() -> _Runtime:
|
|
1554
|
+
global _runtime
|
|
1555
|
+
with _runtime_lock:
|
|
1556
|
+
if _runtime is None or _runtime.closed:
|
|
1557
|
+
_runtime = _Runtime(**_runtime_config)
|
|
1558
|
+
runtime = _runtime
|
|
1559
|
+
runtime._ensure_started()
|
|
1560
|
+
return runtime
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _shutdown_at_exit() -> None:
|
|
1564
|
+
try:
|
|
1565
|
+
Task.shutdown(grace_timeout=0.2, worker_timeout=0.2)
|
|
1566
|
+
except BaseException:
|
|
1567
|
+
_LOG.exception("Error during TinyTask interpreter shutdown")
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
atexit.register(_shutdown_at_exit)
|
|
1571
|
+
|
|
1572
|
+
def task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
|
|
1573
|
+
@functools.wraps(async_function)
|
|
1574
|
+
def wrapper(*args: Any, **kwargs: Any) -> Task[Any]:
|
|
1575
|
+
return Task.run(async_function, *args, **kwargs)
|
|
1576
|
+
|
|
1577
|
+
return wrapper
|