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 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