diff --git a/AGENTS.md b/AGENTS.md index 523418ae..26a12197 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,19 +51,18 @@ Docs are markdown under `docs/`, published only via GitHub Pages never imported by `bledev/__init__.py` or a backend. `AutoDisplay` is `displaydev.auto` only — never re-exported from `displaydev/__init__.py`. Backends must not import `.auto`. - Likewise, synchronous `Timer` providers are explicit `multimer` modules - (`multimer.machine`, `multimer.librt`, `multimer.win32`, `multimer.sdl2`, - `multimer.threading`, or `multimer.polling`). Automatic selection is - `multimer.auto` only and providers must not import it. The package root is - backend-neutral and owns shared clocks, scheduling, `AsyncTimer`, and the - lazy `asyncio` export. + Likewise, `multimer`'s wake sources are private modules + (`multimer._src_signal`, `_src_pending`, `_src_asyncio`, `_src_machine`, + `_src_wasm`, `_src_native`, `_src_none`) that the dispatcher picks once, on + the first armed timer; nothing else imports them, and `import multimer` + touches no host mechanism. There is one public `Timer`. ## Do not - Put product libraries or their release pipeline back in the examples repo. - Instantiate `appdev.App` (or any traffic controller) in a board config. - Import `displaydev.auto` from `displaydev/__init__.py` or any backend. -- Import `multimer.auto` from `multimer/__init__.py` or any provider. +- Import a `multimer._src_*` module from anywhere but the dispatcher. - Commit large generated assets unrelated to boards/drivers. - Rename the GitHub repo casually — MIP URLs and docs pin this name. - Add `board_peripherals.py` under `board_configs/cp/`. diff --git a/board_configs/cp/fbdisplay/qualia_tl040hds20/board_config.py b/board_configs/cp/fbdisplay/qualia_tl040hds20/board_config.py index 0832d875..57210924 100644 --- a/board_configs/cp/fbdisplay/qualia_tl040hds20/board_config.py +++ b/board_configs/cp/fbdisplay/qualia_tl040hds20/board_config.py @@ -107,8 +107,4 @@ def read(self): keypad = _Keypad() touch_read = _touch_points -# Sync + multimer polling Timer: CircuitPython has no machine.Timer and -# (on this build) no frozen asyncio — timer_async would use _mpasyncio and -# leave LVGL unarmed / blank after ``import lv_test_timer``. -timer_async = False keypad_read = keypad.read diff --git a/board_configs/desktop/board_config.py b/board_configs/desktop/board_config.py index f49cdc93..6b01a689 100644 --- a/board_configs/desktop/board_config.py +++ b/board_configs/desktop/board_config.py @@ -19,7 +19,6 @@ ) host_read = display_drv.get_events -timer_async = env_bool("PYDEVICES_TIMER_ASYNC", display_drv.requires_async_timer) display_drv.fill(0) diff --git a/board_configs/jndisplay/board_config.py b/board_configs/jndisplay/board_config.py index 43442aa9..6a91062a 100644 --- a/board_configs/jndisplay/board_config.py +++ b/board_configs/jndisplay/board_config.py @@ -10,7 +10,6 @@ display_drv = JNDisplay(width, height) host_read = display_drv.get_events -timer_async = display_drv.requires_async_timer display_drv.fill(0) diff --git a/board_configs/psdisplay/board_config.py b/board_configs/psdisplay/board_config.py index e171984c..1d96734f 100644 --- a/board_configs/psdisplay/board_config.py +++ b/board_configs/psdisplay/board_config.py @@ -14,7 +14,6 @@ display_drv = PSDisplay("display_canvas", width, height) host_read = display_drv.get_events -timer_async = display_drv.requires_async_timer display_drv.fill(0) diff --git a/docs/android.md b/docs/android.md index 62d24bf1..85ec0102 100644 --- a/docs/android.md +++ b/docs/android.md @@ -130,10 +130,10 @@ still launches `main.py` first. | Looping entry + **Ctrl+C** | `KeyboardInterrupt`, then banner + `>>>` | | Bare `android.py -i` | Clean `>>>` (`main.py` removed for this session) | -With `multimer` **threading** (`timer_async=False`, Android's usual path) there is -no MicroPython soft-IRQ into the REPL mid-loop — matching `micropython.exe -i` on -Windows desktop. MicroPython's signals / `machine.Timer` path can return from -`run` immediately so `>>>` coexists with ticks; Android does not fake that. +`multimer`'s `pending` source delivers between two bytecodes of the main +thread, so `>>>` coexists with ticks on Android as it does on a board: the +prompt is served while it waits, and a long statement typed there is +interrupted by the app's timers like any other main-line code. TTY editing aims for MicroPython REPL parity: @@ -172,10 +172,11 @@ not drive the Android window size; desktop `SDLDisplay` still uses software ## Timers -`multimer` skips auto **`sdl2`** on Android — CPython's `SDL_AddTimer` is not on -the GLES thread and raises `EGL_BAD_ACCESS`. Auto-select falls through to -**`threading`**; the launcher also sets `MULTIMER_BACKEND=threading`. -See [multimer](multimer.md). +`multimer` uses its `pending` source on Android: a worker thread keeps time +and the callback runs on the main (GLES) thread between two bytecodes, so +SDL's timer thread is never involved and `EGL_BAD_ACCESS` has no path to +happen through. Nothing needs setting in the launcher. See +[multimer](multimer.md). ## Audio diff --git a/docs/app-and-board-config.md b/docs/app-and-board-config.md index cc535b18..e8a1fde0 100644 --- a/docs/app-and-board-config.md +++ b/docs/app-and-board-config.md @@ -15,7 +15,6 @@ application decides which coordinator, if any, to instantiate. | `keypad_read` | optional | Keypad reader | | `encoder_read` / `encoder_button_read` | optional | Encoder readers | | `joystick_driver` / `emulate` | optional | Joystick input and optional emulation mapping | -| `timer_async` | optional | Host preference for async timing | Board configs do not import `appdev` and do not export `app`. @@ -41,7 +40,6 @@ You may also provide overrides: app = appdev.App( board_config, refresh_period=16, - timer_async=True, ) ``` @@ -65,7 +63,6 @@ appdev.App( touch_read=None, touch_rotation_table=None, refresh_period=None, - timer_async=False, ) ``` @@ -119,18 +116,17 @@ because `SystemExit` cannot be raised usefully from an interpreter exit hook. ### How `app.run()` Behaves -When called explicitly, `app.run()` adapts to the interpreter environment and timer model: +When called explicitly, `app.run()` adapts to the host: -1. **Interactive REPL (`python -i`, `micropython -i`, MCU prompt)**: - - When running with hardware interrupts or signal-based timers (`machine.Timer`, Linux `librt`, Windows `uwin32`), `run()` **immediately returns**. - - The interactive prompt (`>>>`) stays open for live debugging and introspection while the UI continues running and responding to inputs in the background. +1. **A host that owns a loop (`python -i`, `micropython -i`, an MCU prompt, a + notebook, a browser page)**: `run()` **returns at once**. The prompt (or + the page) stays open for live debugging and introspection while the UI + keeps running; `multimer.report()` shows its timers. -2. **Standalone Desktop CLI (`python app.py`)**: - - In non-interactive desktop scripts, `run()` **sleeps in a keep-alive loop** until a quit event occurs. - - This prevents the desktop OS process from exiting immediately after drawing the initial window. - -3. **Async / Cooperative / Pumped Modes (`asyncio`, CircuitPython, Browser)**: - - `run()` runs the event loop continuously to pump timer ticks and process queued events. +2. **A script (`python app.py`, `code.py`)**: `run()` blocks in + `multimer.run_until`, delivering timers and events, until a quit event. + Without `run()` the interpreter's exit hook does the same after the last + line, so the call is optional. Or an application can explicitly poll: @@ -146,20 +142,6 @@ Display-only MCU applications can omit `appdev` entirely and call `display_drv.show()` according to their own policy. -## `timer_async` - -Board configs publish a neutral `timer_async` preference. Current defaults are: - -| Host | Value | -|---|---| -| PyScript / Jupyter | `True` | -| PG/SDL desktop | `False`, optionally overridden by `PYDEVICES_TIMER_ASYNC` | -| MCU board config | selected by that config | - -Examples do not read the environment variable directly. The selected -coordinator consumes `board_config.timer_async`; test harnesses can use their -`--timer-async` option. - ## Touch read contract `touch_read` is called once per poll. It returns either a falsy value for no @@ -205,6 +187,6 @@ thread spawned from a soft timer or an input callback — that overflows the sta (`Stack protection fault` in task `mp_thread`). Queue the work and run it on the main tick instead: `appdev.App.on_tick`, -an LVGL `lv.timer`, or a soft [`multimer.auto.Timer`](multimer.md) pump. Keep UI +an LVGL `lv.timer`, or a [`multimer.Timer`](multimer.md). Keep UI mutations on that same main path. Desktop CPython can still use threads freely — this constraint is specific to MCU MicroPython. diff --git a/docs/architecture.md b/docs/architecture.md index 1e2e0e16..0f9712af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,7 +57,7 @@ flowchart TB | `displaydev` | Cross-platform display interfaces (`BusDisplay`, `FBDisplay`, `SDLDisplay`, `PyScriptDisplay`). | | `audiodev` | Cross-platform audio output/input interfaces (`I2SAudio`, `SDLAudio`). | | `events` / `keys` | Neutral event definitions, key codes, modifier keys, and touch gestures. | -| `multimer` | Cross-platform timing primitives (explicit `Timer` providers, optional `auto`, `AsyncTimer`, and ticks). | +| `multimer` | One `machine.Timer`-shaped `Timer` on every host, with the clock, `sleep_ms`, `schedule`, `hold` and `report`. | | `appdev` | Optional event traffic controller and input queue for applications using native PyDevices dispatch. | | `display_driver` | LVGL coordinator bridging LVGL widgets to `displaydev` and `multimer`. | diff --git a/docs/board-configs.md b/docs/board-configs.md index f7c9030c..ecdfa530 100644 --- a/docs/board-configs.md +++ b/docs/board-configs.md @@ -173,7 +173,6 @@ exports hardware only: ```python display_drv = AutoDisplay(...) host_read = display_drv.get_events -timer_async = env_bool("PYDEVICES_TIMER_ASYNC", display_drv.requires_async_timer) ``` An application opting into `appdev` then creates its own traffic controller: @@ -187,31 +186,17 @@ app = appdev.App(board_config) LVGL instead creates an independent coordinator in `display_driver`. -| Branch | `display_drv.requires_async_timer` | `timer_async` export | -|--------|-----------------------------------|-------------------------------| -| PyScript / Jupyter | `True` | `True` (default; **`PYDEVICES_TIMER_ASYNC=0` → appdev.App raises**) | -| PG/SDL desktop | `False` | `False` unless **`PYDEVICES_TIMER_ASYNC`** is set | - -`appdev.App` rejects `timer_async=False` when any attached display has -`requires_async_timer` (PS/JN), so a forced sync override fails at construction -instead of hanging. - Panel size overrides (before `import board_config`): `PYDEVICES_WIDTH`, `PYDEVICES_HEIGHT`, `PYDEVICES_ROTATION`, `PYDEVICES_SCALE`. Apps should read geometry from `display_drv`, not module-level names on `board_config`. -Set the env var **before** `import board_config` (or any import that loads it). -Truthy: `1`, `true`, `yes`, `on`. Falsey: `0`, `false`, `no`, `off`. Unknown -values fall back to the desktop default (`False`). Parsing lives in -[`displaydev.env_bool`](https://github.com/PyDevices/pydevices/blob/main/lib/displaydev/__init__.py). - -```bash -# Force asyncio timers on desktop (LVGL async smoke, matrix column) -PYDEVICES_TIMER_ASYNC=1 python my_example.py -``` +Set the env vars **before** `import board_config` (or any import that loads +it). Parsing lives in +[`displaydev.env_int`](https://github.com/PyDevices/pydevices/blob/main/lib/displaydev/__init__.py) +and friends. -Per-board configs under `board_configs/` may export `timer_async`; they never -construct a app. +Per-board configs under `board_configs/` describe hardware; they never +construct an app. ## Custom config diff --git a/docs/displaydev.md b/docs/displaydev.md index eb61fe1c..b2e6f93a 100644 --- a/docs/displaydev.md +++ b/docs/displaydev.md @@ -72,10 +72,7 @@ input source depends on what each platform exposes: With appdev, handlers see the same `events` objects, so application code does not need to know which backend is active. LVGL instead connects these neutral -backend capabilities through its own `display_driver` coordinator. Desktop board configs also use -`timer_async=env_bool("PYDEVICES_TIMER_ASYNC", display_drv.requires_async_timer)` -(`requires_async_timer` is `True` only on PS/JN). `appdev.App` raises if -`timer_async=False` while any attached display has `requires_async_timer`. +backend capabilities through its own `display_driver` coordinator. ### Desktop (SDL2, PyGame) @@ -124,7 +121,6 @@ display_drv = PSDisplay("display_canvas", width, height) app = appdev.App( displays=[display_drv], host_read=display_drv.get_events, - timer_async=display_drv.requires_async_timer, ) ``` @@ -173,7 +169,7 @@ Anything you can draw on implements the framebuf API: pydevices-examples does not include a task scheduler. Options: - **`asyncio`** — works on CPython, MicroPython, and PyScript (required there) -- **[multimer](multimer.md)** — explicit or auto-selected `Timer` providers for sync loops; `AsyncTimer` for async/PyScript apps +- **[multimer](multimer.md)** — one `Timer` on every host; it rides the page's loop on PyScript ## Vertical scrolling diff --git a/docs/jupyter.md b/docs/jupyter.md index c4bf4dbf..994efe7f 100644 --- a/docs/jupyter.md +++ b/docs/jupyter.md @@ -2,7 +2,7 @@ PyDevices runs in JupyterLab, Jupyter Notebook, and VS Code / Cursor notebooks through the **`JNDisplay`** backend. `displaydev.auto.AutoDisplay` detects the -notebook (`get_ipython()`) and selects it with `timer_async=True`. +notebook (`get_ipython()`) and selects it. Board config: [`board_configs/jndisplay/board_config.py`](../board_configs/jndisplay/board_config.py). It exports the Jupyter display and host reader; `appdev.App(board_config)` @@ -54,8 +54,9 @@ See [Displays → how displays expose input](displaydev.md). ## Async execution model The kernel already runs an `asyncio` loop, so a blocking poll loop would starve it -and never receive widget events. The Jupyter board config therefore exports -`timer_async=True` and the application coordinator consumes that preference. +and never receive widget events. `multimer` uses that loop as its wake source +in a notebook (`multimer.report()` in a cell says `source=asyncio`), so timers +armed in one cell keep firing between cells and nothing is configured. Subscribe callbacks and the app keeps itself alive — the kernel's loop is the host loop (`app.strategy == "ambient"`), so no trailing `app.run()` is needed. @@ -66,18 +67,18 @@ Jupyter, `run_async` schedules `main` as a background task and returns immediately (the cell finishes while the coroutine continues); on desktop or MCU with no loop running yet it blocks via `asyncio.run`. -Custom wait-for-touch loops should import `asyncio` from `multimer` and -`await asyncio.sleep(0)` each iteration so the kernel can dispatch widget events +Custom wait-for-touch loops should `await multimer.asleep_ms(0)` (or the +loop's own sleep) each iteration so the kernel can dispatch widget events between polls. See [App and board config](app-and-board-config.md) and [multimer](multimer.md). ## Stopping a running example A task scheduled with `run_async` / `create_task` runs in the background on the kernel loop, so the cell returns immediately and the square **Stop** button will -not interrupt it — use **Kernel → Restart**. Synchronous examples -(`timer_async` false) keep the cell running, and **Stop** raises -`KeyboardInterrupt` there; such examples should call `sleep_ms(1)` each iteration -so Stop can take effect. +not interrupt it — use **Kernel → Restart**. An example that blocks in its +own loop keeps the cell running, and **Stop** raises `KeyboardInterrupt` +there; such examples should call `multimer.sleep_ms(1)` each iteration so +Stop can take effect. ## VS Code / Cursor diff --git a/docs/multimer-internals.md b/docs/multimer-internals.md index 59fd08a5..750698ac 100644 --- a/docs/multimer-internals.md +++ b/docs/multimer-internals.md @@ -1,94 +1,114 @@ -# Timer backend internals & platform capabilities - -This document explains the internal architecture of `multimer`: how timer providers are selected, the underlying C-binding and threading capabilities of each Python interpreter, and how PyDevices bridges hardware interrupts, OS signals, and SDL2 event pumps. - -Importing `multimer` itself never selects a synchronous timer provider. An -application imports a provider explicitly, such as -`from multimer import librt as timer`, or opts into platform selection with -`from multimer import auto as timer`. The final column below describes what -`multimer.auto` normally selects; it is not a package-root default. - -For the general user guide and quickstart, see [multimer](multimer.md). For display driver integration, see [Display backend internals](displaydev-internals.md) and [App and board config](app-and-board-config.md). - ---- - -## Platform capabilities matrix - -The table below details the underlying system capabilities available to `multimer` across all supported interpreters: - -| Interpreter / Executable | Target Platform | FFI / C-Bindings | Threading Support | SDL2 Provider | Signal / Interrupt Timers | Normal `multimer.auto` Provider | -|---|---|---|---|---|---|---| -| **CPython** (`python`) | Linux Desktop | `ctypes` | Full `threading` + `_thread` | `usdl2.py` (via `ctypes`) or `pygame` | POSIX real-time signals (`librt`) | `librt` (`uses_interrupts=True`) | -| **MicroPython** (`micropython`) | Linux Unix port | `ffi` + `uctypes` | Built-in `_thread` | `usdl2.py` (via `ffi`) | POSIX real-time signals (`librt`) | `librt` (`uses_interrupts=True`) | -| **CircuitPython** (`circuitpython`) | Linux port | None | Built-in `_thread` | `displayif` (compiled C module) | None | `sdl2` / `polling` (`uses_interrupts=False`) | -| **CPython** (`python.exe`) | Windows | `ctypes` | Full `threading` + `_thread` | `usdl2.py` (via `ctypes`) or `pygame-ce` | Waitable Timer APCs (`uwin32.py`) | `win32` (`uses_interrupts=True`) | -| **MicroPython** (`micropython.exe`) | Windows Win32 port | `ffi` + `uctypes` | None | `displayif` (compiled C module) | Waitable Timer APCs (`uwin32.py`) | `win32` (`uses_interrupts=True`) | -| **CPython** (`python`) | Android | `ctypes` | Full `threading` + `_thread` | `pygame` / native Android surface | None | `threading` (`uses_interrupts=False`) | -| **MicroPython** | MCU Boards | None / Native C | Port-dependent `_thread` | N/A (Direct panel bus) | Hardware interrupts (`machine.Timer`) | `machine` (`uses_interrupts=True`) | -| **CircuitPython** | MCU Boards | None | None | N/A (Direct panel bus) | None | `polling` (`uses_interrupts=False`) | - -| **PyScript / Pyodide** | Browser / WASM | `js` / `pyodide` FFI | None (single-threaded WASM) | HTML5 Canvas | Browser host loop / Web APIs | internal async provider (`uses_interrupts=False`) | - ---- - -## How SDL2 is bridged (`usdl2.py` vs `displayif`) - -Hosted desktop and simulation targets often use SDL2 for window management, frame presentation, and input polling. PyDevices provides two distinct mechanisms to connect to SDL2 depending on the host's FFI capabilities: - -### 1. Pure-Python FFI Bridge (`usdl2.py`) -When running on **CPython** (Linux/Windows) or **MicroPython Unix** (Linux), the interpreter has access to dynamic foreign function interfaces (`ctypes` or `ffi`): -* [`usdl2.py`](../utils/usdl2.py) dynamically loads the system `libSDL2.so` or `SDL2.dll` at app. -* No C compilation or custom binary build is needed. -* Timer ticks and window pump hooks can be called directly from Python code. - -### 2. Compiled User C Module (`displayif`) -When running on interpreters **without FFI** (such as CircuitPython, or a custom -MicroPython build that omits `ffi`): -* Python cannot load DLLs or shared libraries dynamically. -* The org's [optional aggregator workspace](https://github.com/PyDevices/cmods) compiles `displayif` directly into the interpreter binary as a native C module (`usdl2`). -* Python code imports `usdl2` as a built-in module, exposing identical SDL function signatures without requiring runtime FFI. - ---- - -## Signal & Interrupt Timer Delivery - -Providers with `uses_interrupts is True` deliver callbacks directly to the -main thread through interrupts, signals, or equivalent OS delivery. This -eliminates the need for an application-level timer pump and enables the -**Interactive REPL** debugging workflow. `uses_interrupts` is provider metadata, -not a `Timer` class method, because it describes delivery by the provider as a -whole and also governs `sleep_ms` and `pump` behavior. - -### 1. Linux `librt` (POSIX Signals) -* Uses `timer_create` and `timer_settime` with `SIGEV_THREAD_ID` targeting the main thread. -* On CPython, signal handlers are registered via `signal.signal()`. -* On MicroPython Unix, signal handlers use `ffi` and `uctypes`. -* When a timer expires, the kernel interrupts execution on the main thread and runs the Python callback immediately. - -### 2. Windows `uwin32.py` (Alertable APCs) -* Uses `CreateWaitableTimerExW` and `SetWaitableTimer` with completion APCs (`TIMERAPCROUTINE`). -* When the main thread enters an **alertable wait state** (via `SleepEx(..., alertable=True)` in `multimer.win32.sleep_ms()`, or console I/O read in `python.exe -i`), the Windows kernel delivers the queued APC to the main thread. -* This provides signal-like background execution on Windows without spinning worker threads. - -### 3. Microcontroller `machine.Timer` (Hardware Interrupts) -* On MicroPython boards (ESP32, RP2040, STM32, etc.), `machine.Timer` is backed directly by hardware timer peripherals and ISRs. -* Callbacks are scheduled via `micropython.schedule()`, executing safely on the main VM thread between bytecodes. - ---- - -## MicroPython & CircuitPython Roadmap Considerations - -### `micropython.exe` (Windows) -The PyDevices Windows build includes `ffi` and `uctypes`, allowing the shared -[`uwin32.py`](../utils/uwin32.py) module to call Win32 directly. `multimer.auto` therefore selects -the `win32` provider and uses alertable waitable-timer APCs, matching -`python.exe`. A custom build without `ffi` cannot import that provider and -falls through to `sdl2` (when its compiled `usdl2` module is present) or -`polling`. - -Asyncio remains a build-time option. When a MicroPython build provides none of -`asyncio`, `uasyncio`, or `_asyncio`, the backend-neutral tick and synchronous -timer APIs still work, while arming `AsyncTimer` raises `ImportError`. - -### CircuitPython -CircuitPython intentionally omits `machine.Timer` and low-level FFI in favor of high-level board abstractions and cooperative `asyncio`. Applications running on CircuitPython boards or the Linux port always use `multimer.AsyncTimer` or active sleep-pump loops. +# multimer internals: the dispatcher and the wake sources + +How `multimer` delivers a callback on each host, for anyone changing it or +deciding what a library may do from a callback. The user guide is +[multimer.md](multimer.md). + +## Shape + +``` +Timer ──► one list of armed timers, each with an absolute deadline + │ + ▼ + _dispatch.deliver() runs what is due, re-arms the source + ▲ + ┌─────────────┼─────────────────────────────┐ + │ wake source │ idle points │ + │ arm(ms) │ sleep_ms, pump, the REPL's │ + │ → deliver │ input hook, asleep_ms, the │ + │ │ exit-hook loop, repl() │ +``` + +`_dispatch.py` owns the list, `deliver()`, the guards (no nested delivery, +no re-entering a running timer, `hold()`), the overrun rule, the stats +behind `info()`, and the portable `schedule()` queue. It picks a wake source +on the first arm (`_select_source`) and asks it for one thing: wake me in +*N* ms. Sources are tiny modules with `start(wake)`, `arm(delay_ms)`, +`cancel()`, `stop()`, and two constants, `delivery` and `wakes_blocking`. + +`_hostloop.py` decides who owns the main thread after the script body ends +(ambient host loop, an interpreter exit hook, or nothing), unchanged from its +life in `appdev` except that the exit-hook loop runs while `keepalive` is +set and a timer is armed. `_inputhook.py` is the CPython REPL hook. +`_repl.py` is the in-loop line REPL. + +## The sources + +| Source | Host | Mechanism | Delivery | Wakes a blocked main thread | +|---|---|---|---|---| +| `machine` | MicroPython on a board | one `machine.Timer`, ONE_SHOT, re-armed to the next deadline; its callback is `micropython.schedule`d | bytecode boundary | yes: the REPL and `sleep_ms` run pending callbacks | +| `signal` | unix / macOS CPython and MicroPython | `timer_create` on `SIGRTMIN+4` (Linux) or `setitimer` (elsewhere). CPython: a Python handler at the next bytecode; MicroPython: an ffi handler that only calls `micropython.schedule` | bytecode boundary | yes: EINTR, then the interrupted call is retried (PEP 475 on CPython, `MP_HAL_RETRY_SYSCALL` on unix MicroPython) | +| `native` | MicroPython windows | the `_timing` module from the micropython-pydevices overlay: a Win32 timer queue whose expiry calls `mp_sched_schedule` | bytecode boundary | yes, with the console wait servicing pending callbacks | +| `wasm` | direct MicroPython WebAssembly | `_wasm_bridge.timer_start`, one browser timer; the bridge calls in once the VM is idle, or queues the firing for `sleep_ms` to poll | idle (the page loop) | the loop owns the thread | +| `pending` | CPython Windows, Android (and anywhere as a fallback) | a daemon thread keeps time and calls `Py_AddPendingCall`, at most one outstanding | bytecode boundary | no; `sleep_ms` sleeps only until the next deadline, and the input hook covers the prompt | +| `asyncio` | CPython with a running loop | `loop.call_later` | idle (await points) | the loop owns the thread | +| `none` | CircuitPython, any build with nothing above | – | idle points only | no | + +Selection order: `MULTIMER_SOURCE` if set; `wasm` when `_wasm_bridge` +imports; on MicroPython `machine`, `native`, `signal`; on CPython `asyncio` +when a loop is running or the host is a notebook or PyScript page, then +`signal` on Linux and macOS, then `pending`; `none` last. A source that +fails to start is skipped, and `info()["source_error"]` says why. + +## What "between bytecodes" costs + +A callback delivered at a bytecode boundary runs in the middle of whatever +the main thread was doing in Python. That is the contract `machine.Timer` +has always had on a board, and it is why the audio pump's Python side went +wrong three times on 2026-09 nights: a tick landed between two statements +that assumed they ran together. Two things make it safe now: + +- **`hold()`**: a critical section says so, and the dispatcher masks delivery + until the block ends. +- **Callbacks never interrupt callbacks**: a wake that arrives while + `deliver()` is running is answered when it returns. So a library's timer + callback sees the library's own state whole, as long as the library's + main-line code uses `hold()` around its critical sections. + +The old layer's fragility came from the *delivery paths*: `librt` ran the +whole callback inside the signal handler on MicroPython (heap locked; hence +the `MemoryError` guards), `sdl2` ran it on SDL's thread (which Android's +GLES refused), and `threading` on MicroPython let `micropython.schedule` +drain on the worker. None of those paths exists any more. + +## The overrun rule + +After a callback ends, its next slot is `due += period`. If that slot has +already passed (the callback overran), the grid is stepped past now (each +skipped slot counts in `missed`), and if the callback took longer than a +period, the next slot is pushed to at least `end + min(took, yield_cap)`. +This is the LVGL frame gate of lvgl-bindings#15 and its cap from #19, +applied to every timer: a slow pass halves its own rate rather than taking +the thread, and a pass much longer than a period does not idle the thread +for as long again. `yield_cap = 0` keeps the grid only. + +## `schedule()` + +On MicroPython it *is* `micropython.schedule`. Elsewhere the call is queued +and the source is asked to wake now, so on a bytecode host it runs between +the caller's next two bytecodes, and on an idle host at the next idle +point. A `hold()` masks scheduled work too. The queue is locked for callers +on other threads. + +## The input hook + +readline calls `PyOS_InputHook` about every 100 ms while idle and after each +keystroke; Python 3.13's `_pyrepl` calls it from its own wait loop on the +Unix and the Windows console. `_inputhook.py` waits on stdin *itself* +inside the hook, delivering as deadlines pass and returning the moment a key +arrives (`select` on Unix, `WaitForSingleObject` on the console handle on +Windows), so delivery at the prompt is as punctual as anywhere. The hook is +installed only when the slot is empty (matplotlib and IPython own it +otherwise) and never in a notebook. + +## The host loop + +| Strategy | When | What | +|---|---|---| +| `ambient` | a browser page, a notebook, a MicroPython board's REPL, `-i` | the host's loop outlives the script; register teardown only | +| `exit_hook` | script mode on CPython, MicroPython, CircuitPython | an exit hook takes the main thread after the last line and delivers until `keepalive` is cleared or no timer is armed | +| `none` | `-m` / `-c`, or no hook available | the program blocks itself (`run_until`) | + +A script that crashed does not enter the loop (the crash guard). On +CircuitPython boards the loop drains the serial ring each pass, so Ctrl-C +still means stop. diff --git a/docs/multimer-migration.md b/docs/multimer-migration.md new file mode 100644 index 00000000..612e7887 --- /dev/null +++ b/docs/multimer-migration.md @@ -0,0 +1,74 @@ +# Migrating user code to the new multimer + +What changes for a program, a library, and a board config. The examples +repository's `timing-redesign` branch applies all of this to every example; +this page is the rule those changes follow. The design is in +[timing-design.md](timing-design.md). + +## Timers + +| Before | After | +|---|---| +| `from multimer import auto as timer` | `import multimer` | +| `timer.Timer(-1)` | `multimer.Timer(-1)` (same `init`/`deinit`) | +| `timer.sleep_ms(ms)` | `multimer.sleep_ms(ms)` | +| `timer.pump()` | `multimer.pump()` | +| `timer.uses_interrupts` | `multimer.info()["delivery"] == "bytecode"` (rarely needed: `hold()` replaces the checks libraries did) | +| `timer.is_async`, `timer.name` | gone; `multimer.info()["source"]` names the source | +| `from multimer import AsyncTimer` | `multimer.Timer`: it rides a running asyncio loop by itself | +| `MULTIMER_BACKEND=...` | `MULTIMER_SOURCE=...` (`signal`, `pending`, `asyncio`, `machine`, `wasm`, `native`, `none`) | +| `multimer.schedule`, `ticks_*`, `monotonic`, `loop_running`, `set_deadline_hook` | unchanged | + +A file that only did `from multimer import ticks_ms, ticks_diff` needs no +change. `import multimer as timer` is also a legal one-line migration for a +file that used `timer.Timer` and `timer.sleep_ms`. + +## appdev.App + +| Before | After | +|---|---| +| `App(board_config, timer_async=...)` | `App(board_config)`; the keyword is gone | +| `app.every(ms, fn, async_=app.timer_async)` | `app.every(ms, fn)`; returns a `multimer.Timer` (`.cancel()` still works) | +| `app.on_tick(fn, period=ms, async_=...)` | `app.on_tick(fn, period=ms)` | +| `app.timer_async` | gone (`getattr(app, "timer_async", False)` keeps working: it is `False`) | +| `app.on_start(fn)`, `app.arm_async_refresh()` | gone: timers arm at once on every host | +| `app._timer` (the shared 10 ms timer) | `app.timers`: the service tick, each display's refresh, every subscription | +| `app.run()` | still optional, still blocks in script mode, returns at once under `-i`, a notebook or a page | +| `app.run_async(main)` | unchanged | +| `app.strategy` | unchanged (`"ambient"`, `"exit_hook"`, `"none"`), now `multimer.strategy()` underneath | + +A display-less program that wants to outlive its script body asks: +`multimer.keepalive()`. An `App` with a display or a subscription asks for +it already. + +## Board configs and displays + +- `timer_async = ...` lines in board configs go; nothing reads them. +- `requires_async_timer` on display classes goes. +- A display may set `refresh_period_ms` (default 33) to say how often it + wants to be presented; `App` and the LVGL driver follow it. A backend with + a real frame signal can override `_make_frame_clock`. + +## LVGL programs + +Nothing changes in a program's own code: `import display_driver`, build the +UI, end the script. The driver's `event_loop(freq=..., asynchronous=...)` +still accepts its old arguments; `asynchronous` is ignored. Programs that +called `app.stop_timer()` around draw-buffer creation use +`with multimer.hold():` instead. + +## Libraries + +- A library that runs work from a timer keeps doing so; it gets one `Timer` + per job instead of a share of the App's 10 ms tick, and its callback is + visible in `multimer.report()` under the `name` it gives. +- A library with a critical section that a tick must not interrupt wraps it + in `with multimer.hold():`. That replaces "check `uses_interrupts` and + behave differently". +- Libraries that hand work to the main thread from a worker keep using + `multimer.schedule(fn, arg)`. + +## The test kits + +`PYDEVICES_TIMER_ASYNC` is retired. The examples kit still accepts it and +ignores it; `lv_timer_test_kit.py` has one mode. diff --git a/docs/multimer.md b/docs/multimer.md index 23d74e91..58e8e89f 100644 --- a/docs/multimer.md +++ b/docs/multimer.md @@ -1,286 +1,147 @@ # multimer -`multimer` provides explicit cross-platform timer providers with a -`machine.Timer`-compatible API, plus backend-neutral ticks, scheduling, and -async timing primitives. - -Importing the package root never probes or selects a synchronous backend. - -## Upgrading to 0.1.2 - -Version 0.1.2 is a clean break: it removes package-root synchronous timer -selection and the mutable backend API. There are no compatibility shims. - -| Before 0.1.2 | 0.1.2 replacement | -|---|---| -| `from multimer import Timer` | `from multimer import auto as timer`, then `timer.Timer` | -| `from multimer import sleep_ms` | `timer.sleep_ms` from the selected provider | -| `multimer.uses_signals()` | `timer.uses_interrupts` | -| `multimer.backend_name()` | `timer.name` | -| `multimer.use_backend("polling")` | `from multimer import polling as timer` | -| Import-time backend override | Set `MULTIMER_BACKEND` before importing `multimer.auto` | -| `multimer.backends()` / `backends_available()` | No replacement; import the required provider explicitly | -| `install_asyncio_compat()` / `asyncio_compat` | Import the lazy `asyncio` symbol directly from `multimer` | - -Shared clocks, scheduling, `AsyncTimer`, `loop_running`, and the lazy `asyncio` -export remain at the package root. - -## Choosing a timer provider - -Choose the provider required by the target: - -```python -from multimer import machine as timer # MicroPython MCU -from multimer import librt as timer # Linux signals -from multimer import win32 as timer # Windows APC timer -from multimer import sdl2 as timer # SDL timer/event pump -from multimer import threading as timer # worker + main-thread queue -from multimer import polling as timer # cooperative fallback -from multimer import wasm as timer # direct MicroPython WebAssembly -``` - -Portable host applications can opt into automatic selection: +One `Timer` with `machine.Timer`'s shape, one clock, and one place that +delivers callbacks, on every interpreter PyDevices runs on. The script ends, +the prompt comes back, and the timers keep firing. ```python -from multimer import auto as timer -``` - -Every provider exposes the same module contract: - -| Symbol | Meaning | -|---|---| -| `Timer` | Existing `machine.Timer`-compatible timer class | -| `name` | Selected provider name | -| `uses_interrupts` | `True` when callbacks run without an application pump | -| `is_async` | `True` when `Timer` and `sleep_ms` use asyncio | -| `pump()` | Deliver scheduled callbacks and provider events | -| `sleep_ms(ms)` | Sleep using that provider's interrupt/pump behavior | - -`uses_interrupts` includes MCU hardware interrupts and their desktop -equivalents: Linux real-time signals and Windows alertable APC timers. - -## Sync quick start - -```python -from multimer import auto as timer - +import multimer +from multimer import Timer def on_tick(tim): - print("tick") + print("tick", tim.fired) - -tim = timer.Timer(-1) -tim.init(mode=timer.Timer.PERIODIC, period=500, callback=on_tick) - -while True: - # Required by pumped providers; valid for interrupt providers too. - timer.sleep_ms(10) -``` - -Mode constants live on the timer class (`Timer.PERIODIC` and -`Timer.ONE_SHOT`), matching `machine.Timer`. - -The provider module is conventionally named `timer`; timer instances use names -such as `tim`, `refresh_timer`, or `_timer`. - -## Backend-neutral package API - -Common functions stay at the package root: - -```python -from multimer import ( - AsyncTimer, - asyncio, - loop_running, - monotonic, - run_deadline_hook, - schedule, - set_deadline_hook, - ticks_add, - ticks_diff, - ticks_less, - ticks_ms, -) -``` - -Clock-only code therefore has no timer-backend side effects: - -```python -from multimer import ticks_add, ticks_diff, ticks_ms - -deadline = ticks_add(ticks_ms(), 100) -if ticks_diff(ticks_ms(), deadline) >= 0: - update() +tim = Timer(-1) +tim.init(mode=Timer.PERIODIC, period=500, callback=on_tick) # as on a board ``` -There is no root `Timer` or root `sleep_ms`. Plain hardware initialization -delays should use `time.sleep_ms` (or a `time.sleep` fallback); provider-aware -application loops use `timer.sleep_ms`. +That is the whole program. Run it with `-i` and you are at `>>>` with `tim` +ticking; type `multimer.report()` to see it. Run it without `-i` and the +process exits when the script ends, like a daemon thread, unless something +asks it to stay (an `appdev.App` does, or `multimer.keepalive()`). -## Async timers - -Async applications select `AsyncTimer` directly: +## The API ```python -from multimer import AsyncTimer, asyncio - - -async def main(): - tim = AsyncTimer(-1) - tim.init(mode=AsyncTimer.PERIODIC, period=33, callback=on_tick) - while running: - await asyncio.sleep(0) - - -asyncio.run(main()) +import multimer +from multimer import Timer, every, after, sleep_ms, schedule, hold + +sub = every(33, draw) # a PERIODIC Timer, returned +tok = after(500, done) # a ONE_SHOT Timer +sub.deinit() # or sub.cancel(); machine.Timer's spelling +sleep_ms(100) # sleep; due timers are delivered on the way +schedule(fn, arg) # run fn(arg) at the next safe point +with hold(): # nothing is delivered in here + critical_section() +multimer.report() # what is running, from the REPL ``` -`AsyncTimer.init()` must run while an event loop is executing. Use -`loop_running()` when a library must decide whether it may arm an async timer; +| Function | Meaning | +|---|---| +| `Timer(id=-1)` then `init(mode=, freq=, period=, callback=, hard=)`, `deinit()` | `machine.Timer`'s API. `ONE_SHOT`, `PERIODIC`. Context manager. | +| `every(ms, fn, *, name=None)` / `after(ms, fn, *, name=None)` | a PERIODIC / ONE_SHOT `Timer` | +| `sleep_ms(ms)` | sleep, delivering due timers on every host | +| `pump()` | deliver what is due now; returns ms until the next deadline | +| `schedule(fn, arg)` | `micropython.schedule`'s shape, on every host; from any thread | +| `hold()` | context manager: delivery masked inside, flushed once at exit | +| `keepalive(flag=True)` | keep the process alive past the script's end while timers are armed | +| `run_until(pred, tick_ms=10)` | block, delivering, until `pred()` is true | +| `timers()`, `info()`, `report(file=None)` | what is armed, the dispatcher's state, both printed for a person | +| `repl(namespace=None)` | a line REPL that keeps delivering, for hosts with no prompt (CircuitPython) | +| `asleep_ms(ms)` | coroutine sleep for async code | +| `ticks_ms()`, `ticks_us()`, `ticks_diff()`, `ticks_add()`, `ticks_less()`, `monotonic()` | the clock | +| `strategy()` | how the program stays alive: `"ambient"`, `"exit_hook"`, `"none"` | + +A timer knows about itself: `period`, `mode`, `callback`, `running`, +`due_in`, `fired`, `missed`, `late_max` (ms), `last`, `error` (the last +exception its callback raised), and a settable `name` for `report()`. +`repr(tim)` shows them. + +`MULTIMER_SOURCE=` in the environment forces a wake source (below), +for tests. `import multimer` does nothing to the host; the source is chosen +when the first timer is armed. + +## What a callback can count on + +A callback runs on the main thread, at a safe point: between two bytecodes +where the host can interrupt (a board, a signal, a pending call), otherwise +at the next idle point (`sleep_ms`, `pump()`, the REPL waiting for a key, an +`await`). It never runs on another thread and never inside a C call. + +A callback never interrupts another callback. A callback that is still +running when its next slot comes is not re-entered; the slot is skipped and +counted in `missed`. After a callback runs longer than its period, its next +slot is no sooner than `min(overrun, yield_cap)` later (100 ms by default, +per timer), so a slow pass lowers that timer's rate instead of taking the +thread. Deadlines are absolute, so delivery latency never drifts the +schedule. A callback that raises is printed once and keeps its schedule; +the exception is on `tim.error`. + +Because the host can interrupt between bytecodes, code that must not be +interrupted says so: `with multimer.hold():`. Everything that came due is +delivered once at the end of the block. + +`hard` is accepted for `machine.Timer` parity; every host delivers soft, +which is what `hard=False` means on a board. + +## Hosts + +The dispatcher is the same everywhere. What differs is the *wake source*, +the host's way of getting the main thread's attention, chosen once when the +first timer is armed and named in `report()`: + +| Host | Source | Delivery | Idle prompt served? | +|---|---|---|---| +| MicroPython on a board | `machine` (one `machine.Timer`) | between bytecodes | yes | +| MicroPython unix, macOS | `signal` (a POSIX timer) | between bytecodes | yes | +| MicroPython windows | `native` (the `_timing` module) | between bytecodes | yes | +| MicroPython wasm (direct) | `wasm` (the page's timer) | when the VM is idle | the page loop | +| CPython Linux, macOS | `signal` | between bytecodes | yes | +| CPython Windows, Android | `pending` (a worker thread and `Py_AddPendingCall`) | between bytecodes | yes, through the input hook | +| CPython with a running asyncio loop (Jupyter, PyScript) | `asyncio` (`call_later`) | at await points | the loop | +| CircuitPython | none | `sleep_ms` / `pump()` / `repl()` only | no prompt to serve | + +On CPython, `multimer` also installs a `PyOS_InputHook` that serves timers +while the REPL waits for a key, on 3.11's readline and 3.13's new REPL, on +Unix and Windows. `MULTIMER_INPUTHOOK=0` turns it off. + +On a host without a wake source, `sleep_ms` and `pump()` are the program's +part of the bargain, as they were with the old `polling` provider: a script +that computes without yielding delivers nothing until it yields. + +## Introspection where there is no prompt + +- **Jupyter:** run `multimer.report()` in a cell; timers keep firing between + cells because the kernel's loop is the source. +- **A browser page** (PyScript, the direct wasm build): the same call from + the page's console or REPL; the page loop is the source. +- **CircuitPython:** `code.py` ends with `multimer.repl()`. It reads lines + from the serial port between deliveries and evaluates them in the script's + namespace, so `report()` and the program's own objects are reachable + without stopping it. Ctrl-D returns. +- **A MicroPython board:** the script ends, the REPL comes back, and + `report()` works there like on a desktop. + +## Async code + +Where a host owns an asyncio loop, `multimer` rides it: nothing to configure. +For your own coroutines use `multimer.asleep_ms(ms)` (or the loop's sleep) +and `multimer.loop_running()` when a library must know whether a loop is up; `get_event_loop()` and `get_running_loop()` are not portable enough for that test across MicroPython and CircuitPython. -On PyScript and Jupyter, `multimer.auto` exposes `AsyncTimer` as `timer.Timer`, -sets `timer.is_async = True`, and provides an awaitable `timer.sleep_ms`. - -## Automatic selection - -`multimer.auto` preserves the established selection order: - -```text -wasm → machine → librt → win32 → sdl2 → threading → polling -``` - -Host-specific rules remain: - -- `win32` is auto-tried only on Windows. -- CPython skips `sdl2` when pygame imports, matching `PGDisplay` and avoiding a - dual-SDL deadlock. -- Android skips `sdl2`; its timer callback is not on the GLES thread. -- PyScript and Jupyter select async. -- Direct MicroPython WebAssembly selects `wasm` when `_wasm_bridge` imports. -- A provider which is not installed/importable is skipped. -- `polling` remains the final sync fallback. - -Set `MULTIMER_BACKEND` before importing `multimer.auto` to force a provider: - -```bash -MULTIMER_BACKEND=threading python app.py -``` - -Accepted values are `wasm`, `machine`, `librt`, `win32`, `sdl2`, `threading`, -`polling`, and `async`. An unknown or unavailable forced provider raises; it -never silently falls back. Auto selects once at import and has no mutable -`use_backend` API. - -The selected provider is available as `timer.name`: - -```python -from multimer import auto as timer - -print(timer.name) -``` - -## Interpreter matrix - -| Interpreter / host | Typical auto provider | `uses_interrupts` | Application requirement | -|---|---|---:|---| -| MicroPython MCU | `machine` | `True` | callbacks run from hardware timer delivery | -| CPython Linux | `librt` | `True` | no callback pump required | -| MicroPython Unix | `librt` | `True` | no callback pump required | -| CPython Windows + `uwin32` | `win32` | `True` | use provider sleep for alertable waits | -| `micropython.exe` + `ffi`/`uwin32` | `win32` | `True` | use provider sleep for alertable waits | -| CPython + pygame | `threading` after higher providers fail | `False` | call `pump()` or `sleep_ms()` | -| CircuitPython Unix + usdl2 | `sdl2` | `False` | call `pump()` or `sleep_ms()` | -| `micropython.exe` without `ffi`, with usdl2 | `sdl2` | `False` | call `pump()` or `sleep_ms()` | -| `micropython.exe` without `ffi` or usdl2 | `polling` | `False` | call `pump()` or `sleep_ms()` | -| Android | `threading` | `False` | call `pump()` or `sleep_ms()` | -| PyScript / Jupyter | `async` | `False` | await the host event loop | -| Direct MicroPython WebAssembly | `wasm` | `True` | browser timers deliver on the VM thread | - -Provider selection is independent from display construction. A console app can -have a working timer even when no GUI backend is installed. - -## `hard` and soft delivery - -`Timer.init(..., hard=True|False)` retains MicroPython naming and behavior: - -| `hard` | Delivery | -|---|---| -| `True` | Invoke directly from the backend delivery path | -| `False` | Deliver through `schedule`, with soft coalescing/gap behavior | - -Signal/interrupt providers already deliver on the main thread, so soft delivery -does not necessarily postpone the callback there. It still applies overload -coalescing. On MicroPython, `micropython.schedule` moves soft work out of the -locked-heap interrupt context. - -The SDL provider retains its existing exception: usdl2 already marshals the -callback onto the VM thread, so it does not add another schedule hop. - -## `pump()` and `sleep_ms()` - -Pumped providers deliver queued work only while the main thread cooperates: - -```python -while running: - handle_application_work() - timer.pump() -``` - -`timer.sleep_ms(ms)` performs the same pumping around its wait. Interrupt -providers expose the same two functions, but `pump()` normally has no provider -queue to drain. - -Applications should keep `Timer`, `sleep_ms`, `pump`, and `uses_interrupts` -from one provider module. Mixing them from different providers breaks the -delivery contract. - -## `schedule` - -`multimer.schedule(callback, arg)` matches `micropython.schedule` where -available. On CPython and CircuitPython, off-main calls enter a queue which a -provider pump drains on the main thread. Main-thread calls run immediately -after pending work is drained. - -## Development deadline hooks - -`set_deadline_hook` and `run_deadline_hook` exist for test harnesses and -interactive troubleshooting, especially single-threaded browser hosts. They -are not application lifecycle APIs. - -```python -import multimer - -multimer.set_deadline_hook(check_test_deadline) -try: - run_test() -finally: - multimer.set_deadline_hook(None) -``` - -Provider `sleep_ms` invokes the hook before and after sleeping. App poll -loops invoke `run_deadline_hook()` directly. - ## PyDevices integration -`appdev.App` and LVGL's `display_driver` explicitly opt into -`multimer.auto`. They keep their sync timer, provider sleep, pump, and interrupt -capability together. Async mode uses `AsyncTimer` and `multimer.asyncio`. - -Applications using those coordinators normally call `app.poll()`, -`app.run()`, or `app.run_async()` rather than allocating a -second refresh timer. Most need none of them: `appdev.App` keeps itself alive -past the end of the script body. - -`uses_interrupts` describes how callbacks are *delivered*, not who owns the main -thread — no timer backend can keep a process alive on its own. Note that the -`win32` provider delivers through APCs, so it needs an alertable wait -(`SleepEx(ms, TRUE)`); the app's own loop provides one, a bare REPL prompt does -not. +`appdev.App` is built on this: `app.every()` returns a `multimer.Timer`, the +device service tick and each display's refresh are ordinary timers you see in +`report()`, and an `App` sets `keepalive`. `display_driver` (LVGL) runs LVGL +on one timer that asks LVGL when to come back, and presents from the +display's `frame_clock`. Neither needs `app.run()`. ## Next -- [Timer backend internals](multimer-internals.md) +- [Timer internals and the wake sources](multimer-internals.md) +- [The design, the numbers, and what lost](timing-design.md) +- [Migrating code from the old API](multimer-migration.md) - [App and board config](app-and-board-config.md) - [Displays](displaydev.md) diff --git a/docs/timing-design.md b/docs/timing-design.md new file mode 100644 index 00000000..28837028 --- /dev/null +++ b/docs/timing-design.md @@ -0,0 +1,618 @@ +# The timing layer: multimer, redesigned + +The design behind [multimer](multimer.md), the layer under PyDevices' +displays, LVGL, input, audio pumps, sequencers and apps. Chartered by Brad +on 2026-09-25, designed and built in a cloud session on 2026-09-26, then +landed and run on hardware by a local session the same day. The code is on +the `timing-redesign` branches of pydevices, lvgl-bindings, +pydevices-examples and micropython-pydevices, whose pull requests link +here. The [ledger](#ledger) at the end says what was measured where. + +## What it is + +One `Timer` class with `machine.Timer`'s shape, one clock, and one place +that delivers callbacks, on every interpreter PyDevices runs on. Under it, +one *wake source* per host, chosen once, whose only job is to get the main +thread's attention at the right moment. Timers no longer subclass a +provider; they are entries in a deadline heap that the host wakes. + +```python +import multimer +from multimer import Timer + +tim = Timer(-1) +tim.init(mode=Timer.PERIODIC, period=10, callback=on_tick) # as on a board +sub = multimer.every(33, draw) # sugar: a PERIODIC Timer +tok = multimer.after(500, done) # sugar: a ONE_SHOT Timer +multimer.report() # what is running, from the REPL +``` + +The script ends, the prompt comes back, and the timers keep firing. That +was multimer's goal before and it stays the goal; what changes is how +callbacks reach the main thread, and what you can see while they do. + +## Why it is better + +**Callbacks are delivered at safe points, everywhere.** On a board a +`machine.Timer` callback runs through `micropython.schedule`: between two +bytecodes of the main thread, never inside a C call, never on another +thread. This design gives every host that same contract. CPython gets it +from a signal (Linux, macOS) or from `Py_AddPendingCall` fed by a worker +thread (Windows, Android); unix MicroPython gets it from a signal handler +that does nothing but `micropython.schedule`; the browser gets it from the +page's own loop. Today's `librt` provider runs the whole Python callback +*inside* the signal handler on MicroPython, with the heap locked, which is +where the `MemoryError` guards in `_core.py` came from +(`pydevices/lib/multimer/_core.py:314-321`). The `sdl2` provider runs on +SDL's thread, which is what Android refuses. Those paths are gone. + +**One heap, no catch-up storms.** Every timer is a deadline in one heap. +The wake source is armed to the *earliest* deadline and re-armed after each +delivery, so an idle app wakes exactly when something is due, not every +10 ms. A callback that overruns its period yields for `min(overrun, cap)` +before its next slot, the rule lvgl-bindings#15 and #19 arrived at for LVGL, +applied to every timer. Deadlines are absolute (`due += period`, never +`now + period`), so re-arm latency does not accumulate into drift. + +**Introspection is built in.** `multimer.report()` prints the source, the +delivery model, every live timer with its period, fire count, misses and +worst lateness, and the longest gap the dispatcher has seen between +deliveries. That last number is the "account for every millisecond" meter +from the LVGL performance method (account for every millisecond of a +stall before changing anything), always on. Where there is no `>>>` (Jupyter, a browser page, CircuitPython), +`report()` is the same call from a cell, a console, or the in-loop +line REPL `multimer.repl()` that CircuitPython apps can run instead of a +prompt. + +**The REPL is served, not raced.** On CPython, `multimer` installs a +`PyOS_InputHook` that waits for a keystroke *itself*, delivering due timers +while it waits (select on Unix, the console handle on Windows). So at an +idle prompt callbacks run on time, not at readline's 100 ms poll, and +Python 3.13's new REPL honours the hook on both consoles (verified here on +3.11, 3.12 and 3.13 for Unix; Windows is on the hardware list). No +alertable wait is needed anywhere, so the Windows `-m` freeze +(pydevices-examples#141) has no mechanism left to happen through. + +**Critical sections are a statement, not a rule in a doc.** +`with multimer.hold():` masks delivery; what came due meanwhile is delivered +at the end. That is the tool the audio pump nights needed and did not have: +a scheduled tick cannot re-enter Python inside a held block. + +**The app is not the loop.** `appdev.App` keeps devices, events and refresh +wiring and loses its timer machinery: `app.every()` is `multimer.every()`, +the service tick and the refresh are ordinary timers you can see in +`report()`, and staying alive past the end of the script is multimer's +`keepalive`, which any timer-driven program can ask for, with or without a +display, with or without LVGL. A GUI that reads the devices and presents +the panel itself says so (`app.pause_polling()`, `app.pause_refresh()`) +instead of the App silently stopping every timer it had, which is what the +old LVGL driver did to keep the events for itself. + +## The contract + +### Timer + +`Timer(id=-1)`, `init(mode=PERIODIC, freq=-1, period=-1, callback=None, +hard=False)`, `deinit()`, `ONE_SHOT`, `PERIODIC`: `machine.Timer`'s API. The +callback is `callback(timer)`. `hard` keeps its board meaning where the +board has one (the callback runs in the ISR); on every other host it is +accepted and means soft. Timers are context managers. + +Introspection attributes, read-only: `period` (ms), `mode`, `callback`, +`running`, `fired`, `missed` (slots skipped by the overrun rule), `late_max` +(worst lateness, ms), `last` (`ticks_ms()` of the last delivery), `error` +(the last exception the callback raised, or `None`). `repr(tim)` shows +them. `name` is settable, for `report()`. + +### Delivery + +A callback runs on the main thread, at a *safe point*: a bytecode boundary +or an idle wait. It never runs on another thread and never inside a C +call. If the main thread is executing Python it is interrupted between +bytecodes (`delivery == "bytecode"`); if it is idle in `sleep_ms`, `pump()`, +the REPL's input wait or an asyncio await, it is woken. Where the host can +only offer the idle points (`delivery == "idle"`: CircuitPython, and a +MicroPython build with neither signals nor a machine timer), the docs say so +and `sleep_ms`/`pump()` are the program's part of the bargain, as they were +with the `polling` provider. + +A callback that raises is not fatal: the exception is stored on the timer, +printed once, and the timer keeps its schedule (a periodic timer that +raises every time prints once and counts in `report()`). + +A callback that is still running when its next slot comes is not +re-entered; the slot is skipped and counted in `missed`. After a callback +takes longer than its period, its next slot is no sooner than +`min(overrun, yield_cap)` later (default cap 100 ms, per timer). + +### Module functions + +| Function | Meaning | +|---|---| +| `every(ms, fn, *, name=None)` | new PERIODIC `Timer` | +| `after(ms, fn, *, name=None)` | new ONE_SHOT `Timer` | +| `sleep_ms(ms)` | sleep, delivering due timers on the way, on every host | +| `pump()` | deliver what is due now; returns ms until the next deadline | +| `schedule(fn, arg)` | run `fn(arg)` at the next safe point (`micropython.schedule`'s shape; on MicroPython it *is* `micropython.schedule`) | +| `hold()` | context manager: delivery masked inside, flushed at exit | +| `keepalive(flag=True)` | keep the process alive past the script's end while timers are live (an `App` sets it) | +| `run_until(pred, tick_ms=10)` | block, delivering, until `pred()` is true | +| `timers()` | live timers, a tuple | +| `info()` | a dict: `source`, `delivery`, `host`, `timers`, `max_gap_ms`, `held`, `armed_for_ms` | +| `report(file=None)` | `info()` and every timer, printed for a person | +| `repl(namespace=None)` | a line REPL that keeps delivering while it reads, for hosts with no prompt | +| `ticks_ms`, `ticks_us`, `ticks_diff`, `ticks_add`, `ticks_less`, `monotonic` | the clock, unchanged | +| `asleep_ms(ms)` | coroutine sleep for async code | +| `loop_running()` | unchanged | + +There is no `multimer.auto`, no provider modules to import, no +`uses_interrupts`, `is_async`, `AsyncTimer` or `_defer_sync_arm`. +`MULTIMER_SOURCE=` in the environment forces a wake source, for tests. + +### Wake sources + +Each is a small internal module with `arm(delay_ms)`, `cancel()`, and two +constants, `delivery` and `wakes_blocking`. The dispatcher picks one at +import, in this order, taking the first that imports: + +| Host | Source | Delivery | Wakes a blocked main thread | +|---|---|---|---| +| MicroPython on a board | `machine`: one `machine.Timer`, ONE_SHOT, re-armed to the next deadline; its callback is `micropython.schedule`d | bytecode | yes: the REPL and `sleep_ms` run pending callbacks | +| MicroPython unix, macOS | `signal`: `timer_create` on an RT signal; the ffi handler only calls `micropython.schedule` | bytecode | yes: `read()` returns EINTR and the port runs pending callbacks before retrying (`ports/unix/mphalport.h:94-108`) | +| MicroPython windows | `native`: a C helper thread calling `mp_sched_schedule` (overlay patch, see [MCU and Windows phases](#micropython-on-windows)) | bytecode | with the console-wait patch | +| MicroPython wasm | `wasm`: `_wasm_bridge.timer_start`; the bridge calls the dispatcher when the VM is idle | idle (the page loop) | the loop owns the thread | +| CPython Linux, macOS | `signal`: `timer_create` (Linux) or `setitimer` (elsewhere), a Python handler | bytecode | yes: PEP 475 retries after the handler | +| CPython Windows, Android | `pending`: a worker thread and `Py_AddPendingCall`, one pending call at a time | bytecode | no; the input hook covers the prompt, `sleep_ms` covers sleeps | +| CPython with a running asyncio loop (Jupyter, PyScript, an async app) | `asyncio`: `loop.call_later` | idle (await points) | the loop owns the thread | +| CircuitPython | none | idle | no | + +`report()` names the source in use. The dispatcher is the same code above +all of them; the source only says *when to look at the heap*. + +## How it works + +`multimer/_dispatch.py` holds the heap and the `deliver()` routine. A source +calls `deliver()` from its safe point; `deliver()` pops every timer whose +deadline has passed, runs each callback under the guards above, pushes the +periodic ones back with `due += period`, and re-arms the source to the new +earliest deadline. Idle points (`sleep_ms`, `pump`, the input hook, the +asyncio task, the exit-hook loop) call the same `deliver()`, so a host with +no wake source at all still delivers correctly, just later. + +`multimer/_hostloop.py` is `appdev._hostloop` moved down a layer, with one +change: the exit hook keeps the process alive while `keepalive` is set and +a timer is live, not while an `App` exists. `App` sets `keepalive`; a plain +timer script behaves like a daemon thread and lets the process end, unless +it asks. The `-i` and `-m`/`-c` rules are unchanged. + +`multimer/_inputhook.py` (CPython) installs the `PyOS_InputHook`. The hook +loops: deliver what is due, wait on stdin for `min(next deadline, 50 ms)`, +return the moment stdin is readable. Python 3.11 and 3.12 call it from +readline every 100 ms and at each keystroke; 3.13's `_pyrepl` calls it from +its own wait loop on Unix and Windows (`_pyrepl/unix_console.py:596`, +`_pyrepl/windows_console.py:232`). + +The display's frame clock lives in `displaydev`: `DisplayDriver.frame_clock` +is a `Timer`-shaped object whose period the backend knows (the SDL renderer's +vsync, the browser's `requestAnimationFrame` cadence, a panel's refresh) and +whose `subscribe(fn)` calls `fn` once per frame. The default is a periodic +timer at the backend's `refresh_period_ms`. LVGL's driver presents on it and +sets LVGL's refresh timer to its period. + +## The LVGL driver + +`display_driver.py` runs one ONE_SHOT timer that calls `lv.timer_handler()` +and re-arms itself to what LVGL returns (the ms until LVGL's next timer is +due), bounded above by `LVGL_PERIOD_MS` (10 ms, so input is read at least +that often). A pass that outran what LVGL asked for is followed by at least +one period off, and a pass longer than a period by `min(pass, +max_yield_ms)`: the rule of lvgl-bindings#15 and #19, now in one place +(`event_loop._next_delay`) with a test that reproduces the board's 87 % +without it. `lv.tick_inc` is fed from `ticks_ms()` before each pass, as +before. PARTIAL panels are presented from the display's frame clock, only +after a flush; DIRECT panels present from `flush_is_last`, as before. LVGL's +refresh timer is set to the display's `refresh_period_ms`. The driver +claims device polling and presentation from the App while it runs. Input +is unchanged. + +## Alternatives, and why each lost + +**Keep the provider-per-host `Timer` subclasses, add the design note's +three items** (an earlier design note that kept the interrupt model, +added a display-owned frame clock and a CPython input-hook provider). +It keeps every provider's own delivery path, so the SDL-thread and +in-signal-handler paths stay, and a `hold()` would have to be implemented +seven times. The note's three items are all in this design (callback rules +are enforced by the dispatcher, the frame clock is in displaydev, the input +hook is the CPython idle path); what lost was keeping the shape they were +added to. + +**Threads as the portable source, callbacks on the worker.** Simplest to +write; fails the contract on Android (EGL), on MicroPython with a GIL +(`micropython.schedule` drains on whichever thread runs bytecodes, which is +why `App._dispatch_tick` checks the thread id today, +`pydevices/lib/appdev/app.py:550-557`), and on CircuitPython (no threads on +boards). Threads survive only as the wake mechanism behind `pending`, +where the callback still runs on the main thread. + +**A pure input-hook model on CPython (idle-only, no signals).** It is the +safest: nothing runs between the user's bytecodes. It lost on liveness: a +statement typed at the prompt that runs for a while, or a script section +that computes without yielding, freezes the app, where a board would not. +The design keeps bytecode delivery as the contract and offers the same +safety through `hold()`, which is opt-in per critical section rather than +imposed everywhere. `MULTIMER_SOURCE=asyncio` or `=none` gives the idle-only +behaviour to anyone who wants it. + +**One native timer per `Timer` object** (today's shape). Boards have four +hardware timers on an ESP32; SDL has a thread per timer; RT signals are a +scarce range. A heap behind one native timer costs one `heapq` operation +per delivery and removes all three limits. + +**A fixed 1 ms base tick.** Simple and jitter-free on a board, but 1000 +`micropython.schedule` calls a second on an ESP32 competes with the audio +pump's task for the scheduler queue (depth 8 by default), and on a desktop +it is 1000 wake-ups a second for nothing. Arming to the next deadline costs +one re-arm per delivery instead. + +**`lv.tick_set_cb(ticks_ms)` instead of `tick_inc`.** Cleaner, but LVGL +reads the tick on every timer check and every animation step, and a Python +callback per read is the wrong trade on a board. `tick_inc` once per pass +is one call. + +**Renaming the package.** `timing`, `tempo` and `timedev` were considered. +The `*dev` suffix names device layers, which this is not; the other two say +less than `multimer` does (many timers, one contract), and every consumer, +package list and document already knows the name. The API changes; the name +does not. + +**A `PyOS_InputHook`-only REPL path without the self-wait.** Readline calls +the hook every 100 ms while idle, so a 10 ms timer would fire in bursts of +ten. The hook that waits on stdin itself was measured (below) and is what +ships. + +## Migration + +For user code, in [multimer-migration.md](multimer-migration.md). The short form: replace +`from multimer import auto as timer` with `import multimer` and +`timer.Timer` with `multimer.Timer`; `timer.sleep_ms` with +`multimer.sleep_ms`; `app.every(ms, fn)` still works and returns a `Timer`; +delete `timer_async=` and `AsyncTimer`; a script that ends without +`app.run()` still keeps running when it has an `App`, and a display-less +timer script adds `multimer.keepalive()` if it wants the same. + +## Hardware phases + +Built in the cloud session and run on the bench by the local session: +[timing-hardware-tests.md](timing-hardware-tests.md), which also records +what each run saw. + +### MicroPython on boards + +The `machine` source uses one `machine.Timer` (id -1 where the port +allocates virtual timers, else id 0) in ONE_SHOT mode, re-armed from its own +callback to the next deadline. No interpreter change is needed; the +callback is already delivered through `micropython.schedule` on esp32, rp2 +and stm32 (soft mode). The test plan drives `lv_test_timer.py` and a +pygraphics example on the P4 panel with no `app.run()`, checks `report()` at +the REPL over mpftp, and measures jitter with the bench script against the +current multimer on the same board. + +### MicroPython on Windows + +`micropython.exe` has no signals, and the `uwin32` APC route needs the main +thread in an alertable wait, which the console REPL is not. The overlay +patch (micropython-pydevices, patch 0015) adds a `_timing` +native module to the windows port: one waitable timer (high resolution where +Windows offers it, so it is not bound to the 15.6 ms system tick), waited on +by a helper thread that only sets a flag and signals an event when the +deadline passes. The main thread notices the flag between bytecodes, in +`mp_event_wait_ms`, and in the console wait, and hands the callback to +`mp_sched_schedule` from its own context. The port's own waits — +`MICROPY_INTERNAL_WFE`, the console wait in `mp_hal_stdin_rx_chr`, and the +piped-stdin path — block on that event, so a sleep or a REPL waiting for a +key is served the moment a deadline passes, not at the end of a time slice; +`init()` also asks Windows for its 1 ms timer resolution, as SDL does. +Justification: no Python-level route delivers on the main thread of a build +without threads, and the REPL goal is the charter's first requirement. + +The cloud session's first version used a timer-queue timer and 10 ms wait +slices, built with mingw and run under Wine (the bytecode and `sleep_ms` +paths delivered there, but Wine reports the console handle always signalled +and refuses `PeekNamedPipe`, so it could not show the prompt). On a real +Windows console the bench found that version pinned to the 15.6 ms system +tick — 348/500 idle, 20 callbacks a second at the prompt — which is what the +waitable timer and the 1 ms request fix. On the bench now: `report()` says +`source=native delivery=bytecode`, 507/500 idle and busy with 0.5 ms median +jitter and 4 ms p99 lateness, about 100 a second at the prompt, `hold()` +masks delivery, and a raising callback is counted and printed once. What Wine +could not show — the console and the pipe path, where the REPL goal is +decided — passed on a real Windows console on the bench (`tools/prove_repl/prove_windows.py`). + +### CircuitPython + +No timers, no signals, no threads on boards: delivery is idle-only, from +`sleep_ms`, `pump()`, the exit-hook loop that `code.py` falls into, or an +asyncio task. Introspection is `multimer.repl()`: the loop reads stdin lines +and evaluates them in the script's namespace between deliveries, so +`report()` and the app's own objects are reachable over the same serial port +without stopping the program. Proven on the unix coverage build here; the +board plan is in the test plans. CircuitPython on Linux sits with this +phase: it is the same interpreter with the same limits, and it is where the +in-loop REPL was developed. + +### Android + +The `pending` source runs callbacks on the main (GLES) thread by +construction, so the `threading` fallback and its `pump()` obligation go +away. The plan builds the runner from the patch series and checks the LVGL +launcher and the drum machine on the S21 with `android.py -i`. + +## Numbers + +Measured in this container (4 cores, Linux 6.18; CPython 3.12.3 in a venv +without pygame; unix MicroPython v1.29.0 + overlay 1de7348, kitchen-sink +preset; CircuitPython 10.3.0 unix coverage build), current multimer against +the redesign, same script (`bench_timer.py`, in `tools/timing_bench/` of +pydevices-examples, with the raw `.jsonl` results), same host, quiet machine. "Delivered" is callbacks in 5 s +of a 10 ms timer, expected 500. Jitter is |interval − 10 ms|. Lateness is +deadline-to-callback, which only the redesign can report (the timer knows its +deadline). "Idle" is a main thread in the layer's own `sleep_ms`; "busy" is a +pure-Python loop that never yields. + +### Timer delivery, idle main thread + +| Host | Layer, source | Delivered | Jitter p50 / p99 / max (ms) | Lateness p99 (ms) | CPU | +|---|---|---|---|---|---| +| CPython | current, `librt` | 491 | 0.03 / 9.84 / 9.9 | – | 1.6 % | +| CPython | current, `threading` (Android's path) | 468 | 0.33 / 5.55 / 10.6 | – | 2.0 % | +| CPython | current, `polling` | 418 | 0.58 / 9.95 / 10.0 | – | 11.0 % | +| CPython | current, `sdl2` (callbacks on SDL's thread) | 493 | 0.19 / 0.87 / 1.4 | – | 2.2 % | +| CPython | **redesign, `signal`** | **500** | **0.47 / 0.76 / 0.8** | **1** | 2.3 % | +| CPython | **redesign, `pending`** | **501** | **0.50 / 0.71 / 0.9** | **1** | 3.8 % | +| MicroPython unix | current, `librt` | 452 | 0.01 / 10.0 / 10.0 | – | 4.6 % | +| MicroPython unix | current, `threading` | 336 | 1.36 / 10.4 / 10.6 | – | 8.2 % | +| MicroPython unix | current, `polling` | 443 | 0.30 / 9.66 / 9.8 | – | 5.4 % | +| MicroPython unix | current, `sdl2` | 493 | 0.19 / 0.78 / 1.3 | – | 5.0 % | +| MicroPython unix | **redesign, `signal`** | **500** | **0.11 / 0.39 / 0.4** | **0** | 4.8 % | +| CircuitPython unix | **redesign, `none`** (idle only) | **500** | **0.19 / 0.55 / 0.68** | **0** | 4.4 % | + +The current layer's p99 of 10 ms on `librt` is a whole period: every so +often a tick is dropped by the soft-delivery gap rule (`_core.py:333-336`) +and the next one lands a period late. The redesign drops none and its worst +case is under a millisecond, because deadlines are absolute and delivery is +one heap scan. + +### Timer delivery, busy main thread (no yield at all) + +| Host | Layer, source | Delivered | Jitter p50 / p99 (ms) | Note | +|---|---|---|---|---| +| CPython | current, `librt` | 500 | 0.00 / 0.05 | signal handler between bytecodes | +| CPython | current, `threading`, `polling` | 0 | – | need `pump()` | +| CPython | current, `sdl2` | 493 | 0.37 / 0.77 | on SDL's thread, not the main thread | +| CPython | **redesign, `signal`** | **500** | 0.04 / 0.97 | lateness p99 1 ms | +| CPython | **redesign, `pending`** | **500** | 4.74 / 5.46 | lateness p99 6 ms: the worker needs the GIL, which a busy main thread yields every `sys.getswitchinterval()` (5 ms) | +| MicroPython unix | current, `librt` | 500 | 0.00 / 0.03 | Python inside the signal handler | +| MicroPython unix | current, `threading` | 500 | 0.17 / 0.54 | delivered on the worker thread (the P4 thread-id bug) | +| MicroPython unix | **redesign, `signal`** | **500** | 0.04 / 0.97 | `micropython.schedule` from the handler; callback at a bytecode boundary | + +So the redesign keeps the one thing the interrupt providers were good at +(delivery while the program computes) and gets it on the main thread on +every host that can interrupt. The `pending` source's 5 ms under a +CPU-bound main thread is CPython's GIL switch interval, not the design; a +Windows program that needs tighter can lower `sys.setswitchinterval`. + +### LVGL frame pacing + +`lv_pace.py` (in `tools/timing_bench/` of pydevices-examples): a 320×480 SDL window with the dummy +video driver, an arc moved by a 16 ms LVGL timer, LVGL's default 33 ms +refresh, 3 s. Frames are `REFR_READY` events; the interval is present to +present. + +| Host | Driver | Frames | Interval p50 / p99 (ms) | CPU, animating | CPU, static screen | +|---|---|---|---|---|---| +| CPython | current (10 ms poll, present gate) | 75 | 40.0 / 49.8 | 7.6 % | 8.7 % | +| CPython | **redesign (LVGL-driven, frame clock)** | **91** | **33.1 / 34.4** | 12.3 % | **6.1 %** | +| MicroPython unix | current | 75 | 40.0 / 40.1 | 9.7 % | 9.7 % | +| MicroPython unix | **redesign** | **91** | **33.0 / 34.4** | 13.0 % | **6.0 %** | + +One matched round, the four runs back to back on a quiet machine. CPU +figures moved by two or three points between rounds (the raw files in +`tools/timing_bench/` have three of them); the frame counts and intervals did +not. + +The current driver quantises LVGL's 33 ms refresh to its 10 ms tick and +gates presents to 33 ms, so a frame lands every 40 ms with a 50 ms outlier +every second or so. The redesign runs LVGL when LVGL asks and presents from +the display's frame clock, so frames land at the refresh period to within a +millisecond. It renders 21 % more frames for it, which is where the extra +CPU while animating goes; on a static screen it costs less than the current +driver, because nothing is polled or presented that did not change. A +display that wants fewer frames sets `refresh_period_ms`. + +### REPL delivery + +`prove_repl/prove.py`: `-i` on a real pty, the tick count read twice a second +apart. CPython 3.12 and 3.13 (`_pyrepl`) and unix MicroPython all deliver the +10 ms timer at the idle prompt at its full rate (about 100 ticks a second in +every transcript), `report()` answers there, and the planted fault (no wake +source, hook off) drops MicroPython to zero, as it must. The hook alone, +without waiting on stdin itself, was measured at 15-16 calls in 1.5 s on +all three interpreters: 100 ms, readline's poll. + +## Ledger + +Phase results in order. "Here" means measured in the cloud session that +designed this; "hardware" means measured on Brad's bench by the local session +of 2026-09-26 that landed it (Windows 11, the ESP32-P4 panel, the LilyGO +T-Embed S3, and a Galaxy S21 over adb). Numbers on the bench are their own +runs, not the cloud's; the cloud's container was 4 cores, the bench is 8. + +- **Phase 0, survey and toolchain (here, 2026-09-26).** All 25 repositories + cloned; MicroPython v1.29.0 prepared with overlay 1de7348 and built for + unix with the kitchen-sink preset (lvgl 9.5, usdl2, ffi, `_thread`); + CircuitPython 10.3.0 cloned; emsdk installed; three CPython venvs + (3.12 without pygame, 3.12 with pygame-ce, 3.13). Feasibility probes: + `Py_AddPendingCall` from a worker thread delivers on the main thread on + 3.11/3.12/3.13 (99 of 100 in a busy loop; the pending queue holds 32 and + returns -1 when full, so the source keeps at most one pending); + `PyOS_InputHook` is called about 10 times a second at an idle prompt on + 3.11/3.12 (readline) and 3.13 (`_pyrepl`); on unix MicroPython a + signal handler that only calls `micropython.schedule` delivers in a busy + loop (99/100), in `sleep_ms` (50/50) and at the REPL (250 after 2.5 s). + +- **Phase 1, desktop hosts (here; re-confirmed on the bench).** The redesign + is in `pydevices` (lib/multimer, appdev, displaydev). Its unit suite passes + (`python -m unittest discover -s tests` is green on the bench, 17 skipped). + `prove_repl/prove.py` passes on the bench on CPython 3.12 and unix + MicroPython (and CircuitPython-on-Linux, Phase 4): ticks grow at an idle + `-i` prompt, `report()` answers, keepalive holds a script until it stops, a + crash exits; the planted fault (no source, hook off) fails as it should. The + desktop-Linux numbers above are from this phase. Pygame present or absent + makes no difference to the source chosen (`signal` on Linux either way); the + `sdl2` provider is gone, so there is no dual-SDL path to deadlock. +- **Phase 2, LVGL and pygraphics without `app.run()` (here).** `lv_test_timer.py + kit` passes on CPython and unix MicroPython on the new `display_driver` + (`status ok, taps 1`), and `prove_hostloop` (a pygraphics-shaped app with a + stand-in display) passes its three scenarios on CPython, MicroPython and + the wasm build. Frame pacing is in the numbers. +- **Phase 3, browsers and notebooks (here, partly).** Jupyter: `prove_jupyter.py` + starts an IPython kernel; timers armed in one cell run between cells + (100 → 201 ticks over a second), `report()` says `source=asyncio`, and an + `App` on `JNDisplay` arms and ticks. The direct wasm build: `wasm_host.mjs` + under node runs `demo_timers.py`, returns to the event loop, and reads a + growing count (65 → 165), with `report()` answering. On the way this found + that the workspace's wasm bridge calls `external_call_depth_dec` with no + argument where v1.29.0 takes one, so every timer callback in the direct + wasm build ended in "null function or function signature mismatch" (fatal + under node, a console error in a page); fixed in the micropython-pydevices + series. PyScript and Pyodide could not be run here: pyscript.net and + cdn.jsdelivr.net are refused by this container's egress proxy. The + `asyncio` source is the same code the Jupyter proof exercised, so the + PyScript check is on the local list (a page from pyscript-template with + `multimer.report()` in its console). +- **Phase 4, CircuitPython on Linux (here).** The 10.3.0 unix coverage build + runs the redesign with `source=none`: idle delivery 500/500 with jitter + under 0.7 ms, nothing while busy (by design), `multimer.repl()` on a pty + keeps ticks growing between typed lines and answers `report()`, keepalive + and crash modes pass. +- **Phase 5, Windows, Android, boards (hardware, 2026-09-26).** Run on the + bench; the details and what each run saw are in + [timing-hardware-tests.md](timing-hardware-tests.md), and the numbers are in + [the hardware table below](#numbers-on-hardware). In short: + - **Windows CPython (`python.exe` 3.14) and MicroPython (`micropython.exe`, + overlay patch 0015).** The REPL goal holds in a real console (a Windows + pseudo console, ConPTY): about 100 callbacks a second at an idle `-i` + prompt on both, `report()` answers `source=pending` and `source=native`, + and the planted fault (no source, hook off) stands still. The `-m` freeze + of pydevices-examples#141 has no mechanism left, and the related hang + Brad found on 2026-09-27 — `python.exe -i -m examples.roku_remote`'s + WinDisplay window marked hung for as long as the prompt waits, because + only a timer pumps its message queue — is reproduced on the current + layer and gone on this one (`tools/prove_repl/win_window_alive.py`). + Two things the bench + found that Wine could not: Windows' default 15.6 ms timer resolution held + both layers back, so the `pending` source and `_timing` now ask for 1 ms + as SDL does; and a callback the port had already scheduled was taking a + second trip through the scheduler queue, which held `micropython.exe`'s + idle prompt to 20 a second until the `machine`/`native` sources were made + to deliver directly. + - **Android (Galaxy S21, `pending`).** `source=pending delivery=bytecode + host=cpython/android`: 100 callbacks a second with the main thread idle + and 99 with it spinning in pure Python, so callbacks run on the main GLES + thread with no `threading` fallback and no SDL-timer/EGL hazard. The old + layer there falls back to `threading`: 289 of 400 idle with 111 missed, + and 0 while busy. `MULTIMER_BACKEND=threading` in the runner's `boot.py` + is now dead weight (the redesign reads `MULTIMER_SOURCE`); deleting that + line is a one-line follow-up in android-runner. + - **MicroPython on boards (P4 panel, T-Embed S3, `machine`).** No + interpreter change. Idle jitter fell from a whole 10 ms period with + hundreds of catch-up bursts to well under a millisecond with none (P4: + p50 10 ms / 414 bursts → 0.03 ms / 0 bursts; T-Embed: 7 ms / 131 → 0.5 ms + / 0). LVGL runs on the panel with no `app.run()`: the arc animates and the + seconds count at the REPL, a tap registers, and a 300-iteration Python + loop finishes in 88 ms (P4) while the UI animates instead of being starved + — the frame-gate class of lvgl-bindings#15 stays dead. `report()` shows + the `lvgl`, `app.service` and display refresh timers with their periods + and misses. + - **CircuitPython on a board (T-Embed S3, `source=none`).** Idle-only + delivery, as designed: 84 callbacks a second through `sleep_ms` with the + main thread idle, 0 while it spins, and `report()` answers over the serial + console. Same mechanism as the unix build in Phase 4. + - **Pending:** the LVGL launcher and drum machine on the phone (kept the + screen at brightness 1 for photosensitivity and stayed within the P4/phone + windows); the mechanism they would exercise, main-thread bytecode + delivery, is what the Android numbers already prove. PyScript/Pyodide + pages (the `asyncio` source, the same code the Jupyter proof runs) and an + `mp-wasm` rebuild carrying the bridge fix remain the two browser follow-ups. +- **Phase 6, the deliverables (here).** The four repository series were + exported with `git format-patch` from branches on each repository's + `origin/main`, then re-applied with `git am` onto a fresh checkout of each + recorded base: every series applies and reproduces its branch's tree + exactly. A local session landed them on the `timing-redesign` branches on + 2026-09-26 (pydevices, lvgl-bindings and pydevices-examples on the + recorded bases, which were still `main`; micropython-pydevices rebased + over one commit with no conflict). + +## Numbers on hardware + +Measured on the bench on 2026-09-26, current multimer against the redesign, +same `bench_timer.py`, one 10 ms timer for 5 s, quiet machine. Jitter is +|interval − 10 ms|; lateness is deadline-to-callback, which only the redesign +reports. "Bursts" is callbacks less than a quarter-period apart (catch-up +storms). Idle is a main thread in `sleep_ms`; busy is a pure-Python loop that +never yields. + +### Timer delivery, idle main thread + +| Host | Layer, source | Delivered / 500 | Jitter p50 / p99 (ms) | Lateness p99 (ms) | Bursts | +|---|---|---|---|---|---| +| Windows CPython 3.14 | current, `win32` | 301 | 6.0 / 13.6 | – | 0 | +| Windows CPython 3.14 | current, `threading` | 435 | 0.7 / 6.9 | – | 0 | +| Windows CPython 3.14 | **redesign, `pending`** | **502** | **0.4 / 1.3** | **1** | 0 | +| Windows `micropython.exe` | current, `win32` | 304 | 5.9 / 13.0 | – | 0 | +| Windows `micropython.exe` | **redesign, `native`** | **507** | **0.5 / 3.1** | **4** | 0 | +| ESP32-P4, MicroPython | current, `machine` | 511 | 10.0 / 56.5 | – | 414 | +| ESP32-P4, MicroPython | **redesign, `machine`** | **511** | **0.03 / 4.2** | **4** | 0 | +| T-Embed S3, MicroPython | current, `machine` | 503 | 7.0 / 10.0 | – | 131 | +| T-Embed S3, MicroPython | **redesign, `machine`** | **511** | **0.5 / 1.3** | **8** | 0 | +| T-Embed S3, CircuitPython | **redesign, `none`** (idle only) | 84/s | – | – | 0 | +| Galaxy S21, CPython | current, `threading` | 289 (of 400) | – | – | – | +| Galaxy S21, CPython | **redesign, `pending`** | 100/s | – | 28 (max) | – | + +Windows figures are at the 1 ms timer resolution the redesign now requests; at +the default 15.6 ms the `pending` idle run still delivered 502/500 but a busy +main thread dropped to 327 with 23 ms p99 lateness, which is why the source +raises the resolution while it runs. The board `machine` source needs no +interpreter change. The Android and CircuitPython-board rows are rates over a +1 s window (the probes ran a fixed second, not the 5 s bench). + +### Timer delivery, busy main thread (no yield) + +| Host | Layer, source | Delivered / 500 | Note | +|---|---|---|---| +| Windows CPython 3.14 | current, `win32` / `threading` | 0 | need `pump()` | +| Windows CPython 3.14 | **redesign, `pending`** | **500** | lateness p99 8 ms (the GIL switch interval) | +| Windows `micropython.exe` | **redesign, `native`** | **507** | jitter p99 2.3 ms, lateness p99 4 ms | +| ESP32-P4, MicroPython | current, `machine` | 509 | jitter p99 10.0 ms | +| ESP32-P4, MicroPython | **redesign, `machine`** | **503** | jitter p99 0.7 ms, lateness p99 1 ms | +| T-Embed S3, MicroPython | **redesign, `machine`** | **502** | jitter p99 0.6 ms, lateness p99 1 ms | +| Galaxy S21, CPython | current, `threading` | 0 (of 400) | need `pump()` | +| Galaxy S21, CPython | **redesign, `pending`** | 99/s | on the main GLES thread, no EGL hazard | +| T-Embed S3, CircuitPython | **redesign, `none`** | 0 | idle-only, by design | + +The one thing the interrupt providers were good at — delivering while the +program computes — the redesign keeps, and gets on the *main* thread on every +host that can interrupt. Where a host cannot (CircuitPython), busy delivery is +0 by contract and the program yields with `sleep_ms`/`pump`, as before. + +### LVGL and the REPL on boards + +On both panels `import lv_test_timer` with no `app.run()` leaves the arc +animating and the seconds counting at the REPL, a tap registers, and +`report()` lists the `lvgl`, `app.service` and display refresh timers. The +frame-gate class of lvgl-bindings#15 stays dead: a 300-iteration Python loop +finished in 88 ms on the P4 while the UI animated, rather than being starved +for tens of seconds. The REPL goal holds on `micropython.exe` and `python.exe` +in a real console and on both boards over mpftp: a timer-driven script ends, +the prompt returns, the timers keep firing, and `report()` answers. diff --git a/docs/timing-hardware-tests.md b/docs/timing-hardware-tests.md new file mode 100644 index 00000000..f8613e35 --- /dev/null +++ b/docs/timing-hardware-tests.md @@ -0,0 +1,222 @@ +# Hardware test plans + +What a local session runs to finish the phases the cloud session could +only build ([timing-design.md](timing-design.md)). Each plan says the exact +commands, what a pass prints, and what a failure looks like; under each plan, +**What the run saw** records the result, and the design doc's ledger +summarises them. All of it assumes the `timing-redesign` branches of the +repositories named are checked out beside each other under `~/gh/pydevices`. + +Common setup, once: + +```bash +cd ~/gh/pydevices +export PD=$PWD/pydevices +export PYTHONPATH="$PD/lib:$PD/utils:$PD/board_configs/desktop:$PWD/lvgl-bindings/python" +export MICROPYPATH="$PYTHONPATH:.frozen" +``` + +## Windows: python.exe and micropython.exe + +The class to keep dead: the `-m` freeze of pydevices-examples#141, where +`time.sleep` starved the alertable wait the old `win32` provider needed. +There is no alertable wait in the redesign; `pending` delivers between +bytecodes and the input hook serves the prompt. + +1. **The REPL goal, CPython.** In a Windows terminal (not WSL): + ``` + set PYTHONPATH=%PD%\lib;%PD%\utils + python -i pydevices-examples\tools\prove_repl\demo_timers.py + ``` + Wait two seconds, then type `len(ticks)`, wait, type it again, then + `import multimer; multimer.report()`. Pass: the second count is about + 100 more per second than the first; `report()` says `source=pending + delivery=bytecode`. Fail: the count does not grow (the hook is not being + called: check `python -c "import sys; print(sys.version)"` is 3.11+ and + whether `_pyrepl` is in use on 3.13; `MULTIMER_INPUTHOOK` must not be + `0`). Do it on 3.12 (readline-less console REPL: the count grows while + the prompt is idle, freezes while a line is being typed, which is + expected and documented) and on 3.13 (`_pyrepl`: grows throughout). +2. **The `-m` class.** `python -m examples.google_photos` from + `pydevices-examples\lib` with the display window up: the window repaints + and answers the mouse for a minute. Fail: "Not Responding" in the title + bar. +3. **`python tools\prove_repl\prove.py --python python`** from the examples + repo (the pty harness uses `pty.fork`, POSIX only, so on Windows run the + three demos by hand as in 1 and check `demo_keepalive.py` exits 0 after + printing `stopping at 15`, `demo_crash.py` exits nonzero with `boom`). +4. **micropython.exe.** Build the windows port from the overlay with patch + 0015 (`tools/build_interpreters.sh --only mp-windows`, needs mingw in + WSL) and run the same three demos with `micropython.exe -i` in a real + console. Pass: `report()` says `source=native`, counts grow at the + prompt. Fail: `source=none` means the `_timing` module did not link + (check the variant builds it); a count that grows only while a + statement runs means the console wait is not servicing pending + callbacks (patch 0015's `windows_mphal.c` hunk: `WaitForSingleObject` + on the console handle must return `WAIT_TIMEOUT`, not signalled, while + no key is down). Then the same with stdin from a pipe + (`(sleep 2; echo "print(len(ticks))") | micropython.exe -i demo_timers.py` + from WSL): the pipe path uses `PeekNamedPipe`, which Wine refuses, so + this is the first place it runs for real. The bytecode and sleep paths + were already proven under Wine here (50/50 idle and busy). +5. **Numbers.** `bench_timer.py NEW idle 10 5000` and `busy` on both + interpreters, beside the same run of the old code from `main`; expect + idle jitter under 2 ms (Windows timer resolution permitting) and busy + delivery at the GIL switch interval on CPython. +6. **LVGL.** `python examples\lv_test_timer.py kit` from `lib\` prints + `KIT_RESULT={... "status": "ok", "taps": 1}`; and `python -i + examples\lv_test_timer.py` leaves the window animating at the prompt. + +## Android + +The class to keep dead: SDL's timer callback on a thread EGL refuses. The +`pending` source runs callbacks on the main thread by construction, so the +`threading` fallback and `MULTIMER_BACKEND=threading` in the launcher go. + +1. Build the runner from android-runner with the pydevices series applied to + its recipe pin (or stage the changed `lib/` over `adb` with + `android.py --deps`), install on the S21. +2. `android.py -i pydevices-examples/tools/prove_repl/demo_timers.py`; + type `len(ticks)` twice a second apart. Pass: grows; `multimer.report()` + says `source=pending`. Fail: `source=none` or a stuck count. +3. The LVGL launcher home and the drum machine (`android.py -m + examples.drum_machine`): the UI animates and takes taps for a minute; no + `EGL_BAD_ACCESS` in `adb logcat`. Fail: a black screen after the splash, + or the logcat line. +4. `bench_timer.py NEW idle/busy` on the phone through `android.py`, beside + the old code; expect idle delivery of 500/500 with jitter under 2 ms. +5. Remove `MULTIMER_BACKEND=threading` from the launcher's environment + (android-runner) and delete the Timers paragraph in `docs/android.md` + that explained the fallback; both are in the series. + +## MicroPython on boards (the P4 panel, the T-Embed S3) + +The `machine` source uses one `machine.Timer(-1)` in ONE_SHOT mode, +re-armed from its own (scheduled) callback. Nothing in the interpreter +changes. + +1. Flash the kitchen-sink image; `mpftp put` the changed `pydevices/lib` + (multimer, appdev, displaydev) and `lvgl-bindings/python/display_driver.py` + to `/lib`, which beats the frozen copies. +2. **Function check, no display:** `mpftp exec` the body of + `demo_timers.py` (or put it as `/demo_timers.py` and `import demo_timers`). + At the REPL: `len(demo_timers.ticks)` twice, `multimer.report()`. Pass: + grows; `source=machine delivery=bytecode`. Fail: `source=none` (the + `machine.Timer` constructor raised: check `info()["source_error"]`). +3. **LVGL, no `app.run()`:** `import lv_test_timer` on the P4 panel: the + arc spins and the seconds count at the prompt; a tap on the button + counts. Then `multimer.report()`: the `lvgl` timer, `lvgl.host_pump`, + `app.service` (paused), the display's `frame:` timer. Fail: a blank + panel with `report()` showing the `lvgl` timer with `fired=0` (the loop + never armed: `enable()` was not reached) or an `error=` on it. +4. **pygraphics, no `app.run()`:** `import bouncing_balls` (or `dino`): the + balls move at the prompt. `report()` shows `refresh:FBDisplay` (or the + panel's class) at its period. +5. **The frame-gate class:** lvgl-bindings#15's scenario (a 696×240 + animated bar; `time.sleep_ms(5)` from the REPL while it runs, and a + 300-iteration Python loop). Pass: `sleep_ms(5)` takes about 5 ms and the + loop finishes in seconds, not tens of seconds; `report()` shows the + `lvgl` timer's `missed` climbing while the bar animates (it is yielding). +6. **The audio pump:** `audiolive_rack` or the drum machine with the pump + on; `multimer.report()` while it plays. Pass: no starved packets in the + pump's counters over a minute; `max_gap` in `report()` stays under the + pump's block time. Fail: audible dropouts, or `sched_full` climbing in + `report()` (the scheduler queue is contended; raise + `MICROPY_SCHEDULER_DEPTH` in the board variant as the desktop already + does). +7. **Numbers:** `bench_timer.py NEW idle 10 5000` on the board (`mpftp run`), + beside the old code's `OLD`. Expect 500/500 and jitter under 1 ms on + esp32 (the esp_timer resolution). +8. Wokwi (optional, no token here): `./run.sh boot` then the function check + of step 2 through `drive.py`; never for numbers. + +## CircuitPython on boards (the T-Embed on 10.3.0) + +No wake source: delivery at idle points only, and `multimer.repl()` is the +prompt. + +1. `mpftp put` the changed `lib/` as `.py` (or `.mpy` via mpy-cross for + 10.x) to `/lib`. +2. `code.py`: + ```python + import multimer + ticks = [] + fast = multimer.every(10, lambda t: ticks.append(1), name="fast") + multimer.repl() + ``` + Over the serial port: `len(ticks)` twice a second apart, then + `multimer.report()`. Pass: grows by about 100 a second; `source=none + delivery=idle`. Ctrl-C at the `repl()` prompt returns to CircuitPython's + own REPL (the exit hook's serial drain). Fail: a wedged port (the drain + is not running: `supervisor.runtime.serial_bytes_available` raised). +3. The LVGL example on a CircuitPython build with lvgl-circuitpython: + `import lv_test_timer` from `code.py`; the exit hook drives it. Pass: + the arc spins; Ctrl-C stops it cleanly. +4. **Numbers:** `bench_timer.py NEW idle` only (busy is 0 by design); + expect 500/500 with jitter under 1 ms, as on the unix build. + +CircuitPython on Linux sits with this phase and is already proven here +(the ledger): the same idle-only delivery, `repl()` on a pty, keepalive and +crash modes. + +## What a local session should not do + +Do not run `tools/build_interpreters.sh` with no target on a machine that +has the portal or workbench checked out beside it: `mp-wasm` writes the +runtime into `PyDevices.github.io/vendor/micropython/` and +`workbench/assets/pydevices/`. Run `--only mp-unix` and friends. + +## What the runs saw (2026-09-26) + +Run on the bench by the local session that landed the series. The numbers are +in [timing-design.md](timing-design.md#numbers-on-hardware); this is the +per-plan record of pass/fail and anything found. + +- **Windows, `python.exe` 3.14 — pass.** The REPL goal holds in a real console + (a ConPTY harness, `tools/prove_repl/prove_windows.py`): ~100 ticks/s at an + idle `-i` prompt, `report()` says `source=pending delivery=bytecode`, and the + planted fault (no source, hook off) stands still. Keepalive and crash modes + pass. Numbers as in the table. +- **Windows, `micropython.exe` (overlay patch 0015) — pass.** Built with mingw + under the build lock, `_timing` links (`report()` says `source=native`), + ~100 ticks/s at the prompt in a real console, planted fault stands still. + Two things the real console showed that Wine could not: the default 15.6 ms + timer resolution (both layers), fixed by requesting 1 ms as SDL does; and a + redundant scheduler hop that held the idle prompt to 20 ticks/s, fixed by + delivering directly from sources the port already schedules. Patch 0015 was + revised to a high-resolution waitable timer whose event the port's waits + block on. +- **Windows, a windowed app at the prompt (`python.exe -i -m + examples.roku_remote`) — pass; reproduced first.** Brad's report: the + WinDisplay window is hung (`IsHungAppWindow`) for as long as the REPL sits + at the prompt, on the win32, threading and sdl2 providers alike. Why: + WinDisplay pumps its message queue only inside `get_events()`, which only + the App's 10 ms service tick and LVGL's host pump call, and none of the old + providers can deliver at `_pyrepl`'s non-alertable console wait, so nothing + pumps. `tools/prove_repl/win_window_alive.py` runs the app under a pseudo + console, samples `IsHungAppWindow`, captures with `PrintWindow` (which a + hung window cannot answer) and types into the REPL. Against the installed + 0.5.5 (examples `main`): hung from the third second on, no capture, 3 of 5 + checks fail. Against this series: never hung over 12 s, two captures with + content, `REPL-OK`, `report()` says `source=pending delivery=bytecode` with + `app.service` at 100/s and the `lvgl` timer running, 0 failures. The input + hook is the fix; nothing app-side changed. Only read-only ECP queries were + made (no keys). +- **MicroPython boards (P4, T-Embed S3) — pass.** `machine` source, no + interpreter change. Jitter and bursts as in the table. LVGL runs with no + `app.run()`, a tap registers, the frame-gate loop finishes in 88 ms while + animating, and `report()` lists the timers. REPL goal holds over mpftp. +- **CircuitPython board (T-Embed S3, 10.3.0) — pass.** `source=none`, + idle-only: 84 ticks/s idle, 0 while busy, `report()` over the serial console. + The board was flashed to CircuitPython for this and back to MicroPython + after (a native-USB S3 needs a physical reset to leave DFU ROM mode). +- **Android (S21) — pass.** `source=pending delivery=bytecode + host=cpython/android`, 100 ticks/s idle and 99 busy on the main GLES thread; + the old layer's `threading` fallback managed 289/400 idle and 0 busy. The + LVGL launcher/drum-machine visual pass was not run (screen kept at + brightness 1 for photosensitivity, and within the phone window); the + mechanism it exercises is what the numbers already prove. `--install-apk` + installs the 0.2.3 release; a prior local-key debug build must be uninstalled + first. +- **Not run:** PyScript/Pyodide in a browser (the `asyncio` source, proven via + Jupyter in the cloud) and an `mp-wasm` rebuild carrying the bridge fix. diff --git a/lib/appdev/README.md b/lib/appdev/README.md index aea7d5ea..77103f1a 100644 --- a/lib/appdev/README.md +++ b/lib/appdev/README.md @@ -32,18 +32,17 @@ script body. See [Application lifecycle](../../docs/appdev.md#application-lifecy ## Constructor -### `appdev.App(board_config=None, *, displays=None, host_read=None, touch_read=None, touch_rotation_table=None, refresh_period=None, timer_async=None)` +### `appdev.App(board_config=None, *, displays=None, host_read=None, touch_read=None, touch_rotation_table=None, refresh_period=None)` Instantiates the application coordinator. #### Arguments: -* **`board_config`** *(optional)*: A board configuration module or namespace exporting hardware attributes (e.g. `display_drv`, `host_read`, `touch_read`, `touch_rotation_table`, `keypad_read`, `encoder_read`, `encoder_button_read`, `joystick_driver`, `joystick_emulate_digital`, `timer_async`). +* **`board_config`** *(optional)*: A board configuration module or namespace exporting hardware attributes (e.g. `display_drv`, `host_read`, `touch_read`, `touch_rotation_table`, `keypad_read`, `encoder_read`, `encoder_button_read`, `joystick_driver`, `joystick_emulate_digital`). * **`displays`** *(sequence, optional)*: Sequence of `displaydev` driver instances. Index 0 is primary. If omitted, extracted from `board_config.display_drv`. * **`host_read`** *(callable, optional)*: Polling callable returning raw OS/host events (SDL2/PyGame). * **`touch_read`** *(callable, optional)*: Polling callable returning touch point tuples `(x, y[, ...])`. * **`touch_rotation_table`** *(4-item tuple, optional)*: Bitmask quadrant rotation table for touch mapping (defaults to standard 0/90/180/270° orientation mask). -* **`refresh_period`** *(int, optional)*: Milliseconds between `display.show()` presentation ticks. Defaults to `33` ms (approx 30 FPS) if any attached display has `needs_refresh=True`. Pass `0` or negative to disable periodic refresh. -* **`timer_async`** *(bool, optional)*: Force async timer (`multimer.AsyncTimer`) or synchronous timer (`multimer.auto.Timer`). Auto-detected from display requirements (e.g. PyScript / Jupyter canvas) or `board_config.timer_async` if omitted. +* **`refresh_period`** *(int, optional)*: Milliseconds between `display.show()` presentation ticks. Defaults to each display's `refresh_period_ms` (33 ms unless the backend knows better) when it has `needs_refresh=True`. Pass `0` or negative to disable periodic refresh. --- @@ -130,6 +129,12 @@ with app.refresh_paused(): custom_direct_frame_draw() ``` +#### `app.pause_polling()` +Stops the service tick reading the input devices, for a GUI that polls them itself (LVGL reads its indevs from its own timers). Returns a claim object with `.release()`; `app.resume_polling()` releases too. Without it the service tick consumes the events first. + +#### `app.timers` +The App's own `multimer.Timer`s: the service tick, each display's refresh, and every `every()` subscription still running. `multimer.report()` shows them all. + #### `app.displays` Tuple of attached `displaydev` driver instances (index 0 is primary). diff --git a/lib/appdev/_hostloop.py b/lib/appdev/_hostloop.py deleted file mode 100644 index 1f34bba1..00000000 --- a/lib/appdev/_hostloop.py +++ /dev/null @@ -1,530 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Brad Barnett -# -# SPDX-License-Identifier: MIT -"""appdev._hostloop — decide who owns the main thread after the script body ends. - -``multimer`` answers *how callbacks get delivered*. ``hostloop`` answers the -separate question *who holds the main thread once the script's last line has -run*, which is what ``app.run()`` currently exists to answer. - -Three strategies, chosen once by :func:`install`: - -``ambient`` - The host already runs a loop that outlives the script body: a browser page, - a Jupyter kernel, an MCU REPL after ``main.py``, or ``-i``. Arm the app's - timers and install nothing else. - -``exit_hook`` - Script mode on CPython / MicroPython / CircuitPython. Register an interpreter - exit hook that takes the main thread during shutdown and pumps until the app - quits. The script "drops out the bottom" and the app keeps running. - -``none`` - No mechanism available, or the entry point is ``-m`` / ``-c`` (a test runner - or a one-liner, never an app). The caller must block explicitly - (``app.run()``). - -Private to ``appdev``: it imports nothing from ``appdev`` (or ``multimer``), so -it can be promoted to a top-level package later with a file move and one import -line, but it has no consumer today that does not go through :class:`appdev.App`. - -:func:`install` is idempotent and never raises. -""" - -import sys - -AMBIENT = "ambient" -EXIT_HOOK = "exit_hook" -NONE = "none" - -_state = { - "strategy": None, - "pump": None, - "alive": None, - "on_start": None, - "on_stop": None, - "drive": None, - "started": False, - "stopped": False, - "crashed": False, - "claimed": False, -} - - -# --------------------------------------------------------------------------- -# Host classification -# --------------------------------------------------------------------------- - - -def _impl(): - return getattr(sys.implementation, "name", "") - - -def _mcu(): - """True on microcontroller firmware, as opposed to a desktop OS build. - - Both MicroPython and CircuitPython also build for linux/win32/darwin, and - those builds behave like CPython for lifecycle purposes. - """ - return _impl() in ("micropython", "circuitpython") and sys.platform not in ( - "linux", - "win32", - "darwin", - ) - - -def _main_file(): - """``__main__.__file__``, or None at a bare REPL.""" - m = sys.modules.get("__main__") - if m is None: - try: - import __main__ as m - except Exception: - return None - return getattr(m, "__file__", None) - - -def _cmdline_tokens(): - """Argv tokens including flags, or () when unavailable. - - ``sys.argv`` omits interpreter flags on every implementation we target, so - the real command line has to come from the OS. ``/proc/self/cmdline`` - covers Linux (and Android); ``GetCommandLineW`` covers Windows, including - ``micropython.exe`` where there is no ``/proc``. - """ - try: - with open("/proc/self/cmdline", "rb") as f: - toks = tuple(t.decode() for t in f.read().split(b"\0") if t) - if toks: - return toks - except Exception: - pass - if sys.platform == "win32": - try: - return _win32_cmdline() - except Exception: - pass - return () - - -def _win32_cmdline(): - """Command line via ``kernel32!GetCommandLineW`` (CPython and MicroPython).""" - if _impl() == "micropython": - import ffi - - k32 = ffi.open("kernel32.dll") - get = k32.func("p", "GetCommandLineW", "") - addr = get() - # UTF-16LE, NUL-terminated. Read until a 16-bit zero. - import uctypes - - out = bytearray() - off = 0 - while True: - pair = uctypes.bytes_at(addr + off, 2) - if pair == b"\0\0": - break - out += pair - off += 2 - text = _utf16le(out) - else: - import ctypes - - ctypes.windll.kernel32.GetCommandLineW.restype = ctypes.c_wchar_p - text = ctypes.windll.kernel32.GetCommandLineW() - return tuple(_split_cmdline(text)) - - -def _utf16le(data): - """Decode UTF-16LE by hand: MicroPython has no ``utf-16-le`` codec. - - Without this, micropython.exe could never read its own command line, so - ``-m`` / ``-c`` / ``-i`` all went undetected there. Characters outside the - BMP come out as ``?``; only the ASCII flags matter here. - """ - chars = [] - for i in range(0, len(data) - 1, 2): - code = data[i] | (data[i + 1] << 8) - chars.append("?" if 0xD800 <= code <= 0xDFFF else chr(code)) - return "".join(chars) - - -def _split_cmdline(text): - """Minimal CommandLineToArgvW: enough to spot a bare ``-i`` flag.""" - out = [] - cur = [] - in_quotes = False - for ch in text: - if ch == '"': - in_quotes = not in_quotes - elif ch in " \t" and not in_quotes: - if cur: - out.append("".join(cur)) - cur = [] - else: - cur.append(ch) - if cur: - out.append("".join(cur)) - return out - - -def ambient(): - """True when the host runs a loop that outlives the script body. - - Browser/wasm and Jupyter own the program lifecycle outright. MCU firmware - drops to a REPL after ``main.py``, which keeps hardware timers delivering. - """ - if sys.platform in ("emscripten", "webassembly"): - return True - try: - import pyscript # noqa: F401 - - return True - except Exception: - pass - try: - get_ipython() # noqa: F821 - return True - except Exception: - pass - # MicroPython firmware: boot.py/main.py always lands in the REPL, and - # hardware timers keep delivering there, so the REPL is a real ambient loop. - # - # CircuitPython is deliberately excluded. Its supervisor resets the port - # after code.py returns -- peripherals are deinitialised and nothing keeps - # pumping -- so an app that returns from run() is simply dead. CircuitPython - # must drive its own loop inside run(). - if _mcu() and _impl() == "micropython": - return True - return False - - -def interactive(): - """True when a REPL prompt will remain after the current top-level work. - - Under ``-i`` the REPL *is* the ambient loop, and the user quitting it means - they are done — so the exit hook must not start a second loop afterwards. - """ - if _impl() == "cpython": - flags = getattr(sys, "flags", None) - if getattr(flags, "interactive", 0): - return True - return _main_file() is None - if _mcu() and _impl() == "circuitpython": - # CircuitPython offers no way to tell code.py from the REPL: __main__ - # carries neither __file__ nor __name__ in either case, and - # supervisor.runtime.run_reason reports what triggered the last code.py - # run -- REPL_RELOAD after a Ctrl-D -- not whether one is in progress. - # The distinction does not matter here. CircuitPython delivers no timers - # in the background, so an app has to hold the VM whichever way it was - # started, and the exit hook is the only thing that can hold it. - return False - toks = _cmdline_tokens() - if toks: - if "-i" in toks: - return True - if "-c" in toks or "-m" in toks: - return False - main = _main_file() - return main is None or main in ("", "") - - -def batch(): - """True when the interpreter was started with ``-m`` or ``-c``. - - A module or a command string is a deliberate non-app entry point: test - runners (``python -m unittest``), packaging tools and one-liners all land - here. Taking the main thread at exit in those would hang the runner, so - :func:`install` reports :data:`NONE` and the caller keeps ``app.run()``. - """ - toks = _cmdline_tokens() - return "-m" in toks or "-c" in toks - - -def on_exit(fn): - """Register ``fn()`` to run at interpreter shutdown. True when registered. - - CPython has ``atexit``; MicroPython and CircuitPython expose ``sys.atexit`` - when ``MICROPY_PY_SYS_ATEXIT`` is enabled. ``sys.atexit`` holds one hook, so - hostloop registers exactly one and does all of its shutdown work inside it. - """ - try: - import atexit - - atexit.register(fn) - return True - except ImportError: - pass - hook = getattr(sys, "atexit", None) - if hook is not None: - try: - hook(fn) - return True - except Exception: - return False - return False - - -# --------------------------------------------------------------------------- -# Crash guard -# --------------------------------------------------------------------------- - - -def _crashed(): - """True when the script body died with an uncaught exception. - - Exit hooks run even after an uncaught exception (verified on CPython and - MicroPython), and entering the app loop with a half-built UI hangs the - process instead of surfacing the error. Two signals, because no single one - covers every host: - - * CPython clears the exception before ``atexit`` runs, but has - ``sys.excepthook`` -- wrapped by :func:`_install_crash_guard`. - * MicroPython and CircuitPython have no ``sys.excepthook``, but the - exception is still live in ``sys.exc_info()`` inside the hook. - """ - if _state["crashed"]: - return True - try: - return sys.exc_info()[0] is not None - except Exception: - return False - - -def _install_crash_guard(): - """Wrap ``sys.excepthook`` where it exists (CPython only).""" - prev = getattr(sys, "excepthook", None) - if prev is None: - return - - def _hook(exc_type, exc, tb): - if not issubclass(exc_type, SystemExit): - _state["crashed"] = True - prev(exc_type, exc, tb) - - try: - sys.excepthook = _hook - except Exception: - pass - - -def mark_crashed(): - """Suppress the exit-hook loop (the app is not in a runnable state).""" - _state["crashed"] = True - - -def claim(): - """Tell hostloop the caller is running the loop itself (``app.run()``). - - The exit hook then skips its loop and performs teardown only, so an explicit - ``run()`` and an installed hook never both drive the app. - """ - _state["claimed"] = True - - -def quit(): - """Signal that the app has finished; tear down if nothing else will. - - Under :data:`EXIT_HOOK` the loop notices via ``alive()`` on its next - iteration, so this is a no-op. Under :data:`AMBIENT` there is no loop of - ours to notice -- the host's loop would happily keep driving the app's - timers forever -- so teardown has to happen here. Call from the app's single - quit choke point (``App._handle_quit``). - """ - if _state["strategy"] == AMBIENT: - _stop() - - -# --------------------------------------------------------------------------- -# Lifecycle -# --------------------------------------------------------------------------- - - -def _start(): - if _state["started"]: - return - _state["started"] = True - fn = _state["on_start"] - if fn is not None: - fn() - - -def _stop(): - if _state["stopped"]: - return - _state["stopped"] = True - fn = _state["on_stop"] - if fn is not None: - try: - fn() - except BaseException: - pass - - -def _cp_break_watch(): - """Zero-arg "did the user press Ctrl-C" probe for CircuitPython, else None. - - CircuitPython does not arm Ctrl-C as an interrupt character while an atexit - handler is running: it arrives as ordinary stdin data instead of raising - KeyboardInterrupt. A pump loop never reads stdin, so the USB CDC receive - ring fills, the host's writes start timing out, and the board stops - answering every tool that could recover it -- a hard reset or a 1200-baud - bootloader touch becomes the only way back. Draining the ring each pass - fixes both halves at once: writes keep flowing, and Ctrl-C means quit again. - - Returns None off MCU CircuitPython, where the interpreter handles Ctrl-C - itself and stdin must be left alone. - """ - if _impl() != "circuitpython" or not _mcu(): - return None - try: - import supervisor - - rt = supervisor.runtime - except Exception: - return None - - # Anything already buffered predates this loop and cannot be a request to - # stop it -- it is typically the Ctrl-C/Ctrl-D a host tool used to start the - # run in the first place. Drop that backlog once, or the app quits the - # instant it starts. - try: - backlog = rt.serial_bytes_available - if backlog: - sys.stdin.read(backlog) - except Exception: - pass - - def pressed(): - try: - waiting = rt.serial_bytes_available - if not waiting: - return False - return "\x03" in sys.stdin.read(waiting) - except Exception: - return False - - return pressed - - -def _run_loop(): - pump = _state["pump"] - alive = _state["alive"] - if pump is None or alive is None: - return - # Note: the drive() path (asyncio-backed apps) does not get this treatment; - # it owns its own event loop and would need the equivalent drain in there. - interrupted = _cp_break_watch() - if interrupted is None: - while alive(): - pump() - return - while alive(): - pump() - if interrupted(): - break - - -def _exit_hook(): - # Never let an exception escape: MicroPython turns an uncaught exception in - # sys.atexit into "FATAL: uncaught NLR" (rc=1), and CPython prints an - # "Exception ignored in atexit callback" traceback. - try: - if not _state["claimed"] and not _crashed(): - drive = _state["drive"] - if drive is not None: - # Async app: a synchronous pump loop would never run the event - # loop, and AsyncTimer would silently never fire. Hand the whole - # run to the caller, which owns the asyncio entry point (and is - # responsible for flushing on_start from inside it -- AsyncTimer - # can only arm once a loop is actually running). - drive() - else: - _start() - _run_loop() - except BaseException: - pass - _stop() - - -def install(pump, alive, on_start=None, on_stop=None, drive=None): - """Arrange for ``pump()`` to be called until ``alive()`` is false. - - Args: - pump: Zero-arg callable that services timers/events once. Should sleep - or block briefly so the loop does not spin. - alive: Zero-arg predicate; the loop runs while it returns true. - on_start: Zero-arg callable invoked at the moment the loop begins -- - immediately under ``ambient``, at shutdown under ``exit_hook``. This - is the single well-defined point at which deferred arming can flush. - on_stop: Zero-arg callable invoked after the loop ends (teardown). - drive: Optional zero-arg callable that runs the whole app to completion, - used instead of ``pump``/``alive`` under :data:`EXIT_HOOK`. Required - for apps whose timers are asyncio-backed on a host with no ambient - loop: a synchronous pump loop never runs the event loop, so - ``AsyncTimer`` would silently never fire. ``drive`` owns the asyncio - entry point and must flush ``on_start`` itself, from inside the - running loop (``AsyncTimer.init`` needs one). Ignored under - :data:`AMBIENT`, where the host's loop already runs. - - Returns: - str: :data:`AMBIENT`, :data:`EXIT_HOOK`, or :data:`NONE`. - - The strategy is decided once per process and never re-decided. A later call - rebinds the callbacks to the new owner without registering a second exit - hook, so sequential apps in one process each get driven in turn. - """ - rebind = _state["strategy"] is not None - - _state["pump"] = pump - _state["alive"] = alive - _state["on_start"] = on_start - _state["on_stop"] = on_stop - _state["drive"] = drive - - if rebind: - # A second App superseding the first (sequential apps in one process, or - # a test suite). Keep the strategy and the single registered hook; hand - # them the new owner's callbacks and let it start. - _state["started"] = False - _state["stopped"] = False - _state["claimed"] = False - if _state["strategy"] == AMBIENT: - _start() - return _state["strategy"] - - if ambient() or interactive(): - # The host's own loop (page, kernel, REPL) is already running or about - # to be. Arm now; register teardown only. - _state["strategy"] = AMBIENT - _start() - on_exit(_stop) - return AMBIENT - - if batch(): - # ``-m`` / ``-c``: not an app entry point. Register teardown only. - on_exit(_stop) - _state["strategy"] = NONE - return NONE - - _install_crash_guard() - if on_exit(_exit_hook): - _state["strategy"] = EXIT_HOOK - return EXIT_HOOK - - _state["strategy"] = NONE - return NONE - - -def strategy(): - """The strategy chosen by :func:`install`, or None before it is called.""" - return _state["strategy"] - - -def _reset_for_test(): - for k in _state: - _state[k] = ( - None - if k in ("strategy", "pump", "alive", "on_start", "on_stop", "drive") - else False - ) diff --git a/lib/appdev/app.py b/lib/appdev/app.py index 4b4d153e..7872047f 100644 --- a/lib/appdev/app.py +++ b/lib/appdev/app.py @@ -1,13 +1,25 @@ # SPDX-FileCopyrightText: 2026 Brad Barnett # # SPDX-License-Identifier: MIT -"""App: application coordinator, event dispatcher, timer manager, and run loop.""" +"""App: devices, events, display refresh and lifecycle, on multimer's timers. + +An ``App`` owns no timer machinery of its own. ``app.every(ms, fn)`` is +``multimer.every``; the device service tick and each display's refresh are +ordinary timers you can see in ``multimer.report()``; and staying alive past +the end of the script body is ``multimer.keepalive``, which the App sets +when it has a display or a subscription. So a script that builds its UI and +ends keeps running, at the prompt under ``-i`` and in the exit hook +otherwise, with or without LVGL, and ``app.run()`` is only for a program +that wants to block. +""" import sys + import events import keys +import multimer +from multimer import _hostloop -from . import _hostloop from .devices import ( ENCODER, HOST, @@ -25,31 +37,20 @@ SERVICE_TICK_MS = 10 -class _TimerSubscription: - """Handle for a periodic callback on the App's timer.""" - - def __init__(self, app, entry): +class _RefreshClaim: + def __init__(self, app): self._app = app - self._entry = entry - def cancel(self): - entry = self._entry - if entry is None: - return - self._entry = None - entry[3] = True - try: - self._app._tick_callbacks.remove(entry) - except (ValueError, AttributeError): - pass + def release(self): + self._app.resume_refresh() -class _RefreshClaim: +class _PollingClaim: def __init__(self, app): self._app = app def release(self): - self._app.resume_refresh() + self._app.resume_polling() class _RefreshPaused: @@ -68,7 +69,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): class App: - """Application coordinator: devices, shared timer, display refresh, and lifecycle.""" + """Application coordinator: devices, event dispatch, display refresh, lifecycle.""" _current = None events = events @@ -93,59 +94,32 @@ def __init__( touch_read=None, touch_rotation_table=None, refresh_period=None, - timer_async=None, ): + prev = App._current + if prev is not None and prev is not self: + # A second App in one process (sequential apps, a test suite): + # the first one's timers must not keep dispatching into it. + try: + prev.stop_timers() + except Exception: + pass App._current = self self.devices = [] self._event_callbacks = {} - self._tick_callbacks = [] - self._in_tick_dispatch = False + self._subscriptions = [] self._before_quit = None self._quit_requested = False self._exit_code = None - self._timer = None - - # Stop timers on any previous App instance to avoid duplicate event dispatch on re-runs - prev_app = getattr(App, "_current_app", None) - if prev_app is not None and prev_app is not self: - try: - prev_app.stop_timer() - except Exception: - pass - App._current_app = self - - # Work that cannot be armed yet, flushed the moment the loop starts. - # Replaces the several _pending_* flags that each approximated that - # moment separately; _hostloop supplies it exactly once. - self._deferred = [] - self._loop_started = False - self._strategy = None - self._refresh_subscription = None + self._refresh_timers = [] + self._refresh_period = refresh_period self._refresh_paused = False self._refresh_claim = None - self._refresh_pending = False - self._refresh_period = refresh_period - self._service_subscription = None - self._service_pending = False + self._service_timer = None + self._polling_claim = None self._app_drives_poll = False self._in_service_poll = False - self._pending_teardown = False self._teardown_done = False - self._blocking_run = False - self._ticks_ms = None - self._ticks_add = None - self._ticks_diff = None - - self._timer_thread_ident = None - try: - if sys.implementation.name == "micropython": - import _thread - - self._timer_thread_ident = _thread.get_ident() - except (ImportError, AttributeError): - pass - # Parse displays from arguments or board_config if displays is not None: self._displays = list(displays) elif board_config is not None and getattr(board_config, "display_drv", None) is not None: @@ -161,24 +135,12 @@ def __init__( except Exception: pass - # Determine timer_async - if timer_async is not None: - self._timer_async = bool(timer_async) - elif board_config is not None and hasattr(board_config, "timer_async"): - self._timer_async = bool(board_config.timer_async) - else: - self._timer_async = any( - getattr(drv, "requires_async_timer", False) for drv in self._displays - ) - - # Wire inputs from board_config or explicit kwargs primary = self.primary effective_host_read = ( host_read if host_read is not None - else getattr(board_config, "host_read", None) - or getattr(board_config, "get_events", None) + else getattr(board_config, "host_read", None) or getattr(board_config, "get_events", None) ) if effective_host_read is not None: self.host_dev = HostEvents(host_read=effective_host_read, display=primary) @@ -186,9 +148,7 @@ def __init__( else: self.host_dev = None - effective_touch_read = ( - touch_read if touch_read is not None else getattr(board_config, "touch_read", None) - ) + effective_touch_read = touch_read if touch_read is not None else getattr(board_config, "touch_read", None) effective_touch_table = ( touch_rotation_table if touch_rotation_table is not None @@ -196,9 +156,7 @@ def __init__( ) if effective_touch_read is not None: self.touch_dev = self.add_touch( - effective_touch_read, - display=primary, - rotation_table=effective_touch_table, + effective_touch_read, display=primary, rotation_table=effective_touch_table ) else: self.touch_dev = None @@ -211,10 +169,7 @@ def __init__( encoder_read = getattr(board_config, "encoder_read", None) if encoder_read is not None: - self.add_encoder( - encoder_read, - button_read=getattr(board_config, "encoder_button_read", None), - ) + self.add_encoder(encoder_read, button_read=getattr(board_config, "encoder_button_read", None)) else: self.encoder_dev = None @@ -225,23 +180,17 @@ def __init__( else: self.joystick_dev = None + _hostloop.on_stop(self._teardown_from_loop) if self._displays: self._wire_display_refresh(self._refresh_period) - self._install_hostloop() + self._keep_alive() - @property - def strategy(self): - """How this app stays alive past the end of the script body. - - One of ``"ambient"`` (the host runs a loop of its own), ``"exit_hook"`` - (an interpreter exit hook takes the main thread), ``"none"`` (nothing - available -- :meth:`run` is required), or None before wiring. - """ - return self._strategy + # -- properties -------------------------------------------------------- @property - def timer_async(self): - return self._timer_async + def strategy(self): + """How the program stays alive past the script body (see ``multimer.strategy``).""" + return multimer.strategy() @property def displays(self): @@ -265,6 +214,18 @@ def before_quit(self, value): raise ValueError("before_quit must be callable") self._before_quit = value + @property + def timers(self): + """The App's own timers: service, refresh and every() subscriptions.""" + out = [] + if self._service_timer is not None: + out.append(self._service_timer) + out.extend(self._refresh_timers) + out.extend(t for t in self._subscriptions if t.running) + return tuple(out) + + # -- displays and devices --------------------------------------------- + def add_display(self, drv): if drv is None: raise ValueError("drv is required") @@ -278,13 +239,9 @@ def add_display(self, drv): pass if first: self._wire_display_refresh(self._refresh_period) - self._install_hostloop() - elif ( - getattr(drv, "needs_refresh", False) - and self._refresh_subscription is None - and not self._refresh_pending - ): - self._wire_display_refresh(self._refresh_period) + self._keep_alive() + elif getattr(drv, "needs_refresh", False): + self._wire_one_refresh(drv, self._refresh_period) return drv def remove_display(self, drv): @@ -292,6 +249,10 @@ def remove_display(self, drv): return was_primary = drv is self.primary self._displays.remove(drv) + for t in tuple(self._refresh_timers): + if getattr(t, "_display", None) is drv: + t.deinit() + self._refresh_timers.remove(t) try: drv.app = None except Exception: @@ -346,12 +307,15 @@ def register(self, dev): dev.app = self if dev not in self.devices: self.devices.append(dev) + self._arm_service() def unregister(self, dev): if dev in self.devices: self.devices.remove(dev) dev.app = None + # -- events --------------------------------------------------------------- + def on(self, event_type_or_list, callback=None): """Subscribe callback to one or more event types, or use as a decorator.""" if callback is None: @@ -379,199 +343,59 @@ def off(self, event_type_or_list, callback): if callback_set: callback_set.discard(callback) - def _ensure_ticks(self): - if self._ticks_ms is not None: - return - from multimer import ticks_add, ticks_diff, ticks_ms - - self._ticks_ms = ticks_ms - self._ticks_add = ticks_add - self._ticks_diff = ticks_diff - - @staticmethod - def _event_loop_running(): - try: - from multimer import loop_running + # -- timers ----------------------------------------------------------- - return loop_running() - except ImportError: - return False + def every(self, ms=None, callback=None, *, period=None, name=None): + """A periodic ``multimer.Timer`` calling ``callback(timer)`` every *ms*. - def _arm_ready(self): - """True when a timer can be created right now. - - Only async timers have to wait: they need a running event loop. The - browser is the exception even there -- it owns the loop for the whole - program, including import. - - Deliberately *not* consulting ``_defer_sync_arm``. That flag asks for - the display refresh subscription to be armed from inside the loop; it - does not mean sync timers cannot be created, and gating every timer on - it would leave ``app._timer`` None all the way through UI construction - on those providers. + Usable as a decorator (``@app.every(1000)``). The App keeps the + process alive while any subscription runs; ``timer.deinit()`` (or + ``cancel()``) ends one. """ - if not self._timer_async: - return True - if sys.platform in ("emscripten", "webassembly"): - return True - return self._event_loop_running() - - def _defer(self, fn): - """Run ``fn`` now if the app can arm, else at the moment the loop starts.""" - if self._arm_ready(): - fn() - else: - self._deferred.append(fn) - - def on_start(self, fn): - """Register ``fn()`` to run when the app's loop starts. - - Runs immediately if the loop is already able to arm timers. This is the - single coordination point callers such as ``display_driver`` need in - place of probing for a running event loop themselves. - """ - if not callable(fn): - raise ValueError("fn must be callable") - self._defer(fn) - return fn - - def _flush_deferred(self): - """Arm everything that was waiting for the loop. Called at loop start. - - Only the async gate applies here. A sync provider that sets - ``_defer_sync_arm`` is asking to be armed *from inside* the loop, which - is exactly where this runs -- consulting :meth:`_arm_ready` would keep - deferring forever, since that flag never clears. - """ - if self._timer_async and not self._arm_ready(): - return - self._loop_started = True - while self._deferred: - self._deferred.pop(0)() - - def _install_hostloop(self): - """Arrange for the app to outlive the script body. See ``_hostloop``.""" - self._strategy = _hostloop.install( - pump=self._pump, - alive=lambda: not self._quit_requested and not self._teardown_done, - on_start=self._flush_deferred, - on_stop=self._teardown_from_loop, - drive=self._drive_async if self._timer_async else None, - ) - return self._strategy - - def _pump(self): - from multimer import auto as timer - - timer.sleep_ms(SERVICE_TICK_MS) - - def _drive_async(self): - from multimer import asyncio - - asyncio.run(self._run_async()) - - def every(self, ms=None, callback=None, *, period=None, async_=None): - """Schedule a periodic callback every ms milliseconds, or use as decorator.""" if period is not None: if callable(ms) and callback is None: callback = ms ms = period if ms is None and period is None: - ms = 10 + ms = SERVICE_TICK_MS if callback is None: if callable(ms): callback = ms - ms = 10 + ms = SERVICE_TICK_MS else: - return lambda fn: self.every(ms, fn) + return lambda fn: self.every(ms, fn, name=name) if not callable(callback): raise ValueError("callback must be callable") - self._ensure_ticks() - if self._timer is None: - self._defer(lambda: self._start_timer(async_=self._timer_async)) - entry = [callback, int(ms), self._ticks_add(self._ticks_ms(), int(ms)), False] - self._tick_callbacks.append(entry) - return _TimerSubscription(self, entry) - - def on_tick(self, callback, period=10, async_=None): - """Schedule a periodic callback (wrapper around every).""" + tim = multimer.every(int(ms), callback, name=name) + self._subscriptions.append(tim) + self._keep_alive() + return tim + + def on_tick(self, callback, period=SERVICE_TICK_MS, **_ignored): + """Schedule a periodic callback (alias of :meth:`every`).""" return self.every(period, callback) - def _start_timer(self, *, async_=False, tick_ms=10): - if self._timer is not None: - return self._timer - # A timer is the other reason an app must outlive the script body, so a - # display-less app that only schedules callbacks still gets a host loop. - self._install_hostloop() - from multimer import AsyncTimer - from multimer import auto as timer - - self._ensure_ticks() - timer_class = AsyncTimer if async_ else timer.Timer - timer_inst = None - last_err = None - for timer_id in (-1, 0, 1, 2, 3): - try: - timer_inst = timer_class(timer_id) - break - except ValueError as exc: - last_err = exc - if timer_inst is None: - raise last_err - timer_inst.init( - mode=timer_class.PERIODIC, - period=tick_ms, - callback=self._dispatch_tick, - hard=False, - ) - self._timer = timer_inst - return timer_inst - - def stop_timer(self): - """Stop the shared timer and clear all periodic subscriptions.""" - self._tick_callbacks.clear() - timer_inst = self._timer - self._timer = None - self._refresh_subscription = None + def stop_timers(self): + """Stop the service tick, every refresh and every subscription.""" + st = self._service_timer + self._service_timer = None + if st is not None: + st.deinit() + for t in self._refresh_timers: + t.deinit() + self._refresh_timers = [] + for t in self._subscriptions: + t.deinit() + self._subscriptions = [] self._refresh_paused = False self._refresh_claim = None - self._refresh_pending = False - self._service_subscription = None - self._service_pending = False - self._deferred.clear() - if timer_inst is not None: - try: - timer_inst.deinit() - except Exception: - # Never let a provider's disarm abort the rest of teardown -- - # displays still have to be released. - pass - def _dispatch_tick(self, timer_obj): - if self._timer_thread_ident is not None: - try: - import _thread + stop_timer = stop_timers - if _thread.get_ident() != self._timer_thread_ident: - return - except (ImportError, AttributeError): - pass - if self._in_tick_dispatch: - return - self._in_tick_dispatch = True - try: - now = self._ticks_ms() - for entry in tuple(self._tick_callbacks): - if entry[3]: - continue - if self._ticks_diff(entry[2], now) > 0: - continue - entry[2] = self._ticks_add(now, entry[1]) - entry[0](timer_obj) - if self._pending_teardown and not self._blocking_run: - self._try_perform_teardown() - finally: - self._in_tick_dispatch = False + def _keep_alive(self): + multimer.keepalive(True) + + # -- refresh ---------------------------------------------------------- def pause_refresh(self): """Pause display refresh while a GUI renders frames.""" @@ -592,64 +416,58 @@ def refresh_paused(self): """Context manager to pause display refresh within a block.""" return _RefreshPaused(self) + def pause_polling(self): + """Stop the service tick reading the devices: the caller reads them. + + A GUI that polls the input devices itself (LVGL reads its indevs + from its own timers) claims the devices with this, or the service + tick consumes the events first. ``release()`` the claim to resume. + """ + if self._polling_claim is not None: + raise RuntimeError("device polling already claimed") + self._polling_claim = _PollingClaim(self) + return self._polling_claim + + def resume_polling(self): + self._polling_claim = None + def _wire_display_refresh(self, refresh_period): - if not self._displays: - return self._arm_service() - needs = any(getattr(d, "needs_refresh", False) for d in self._displays) + for display in self._displays: + self._wire_one_refresh(display, refresh_period) + + def _wire_one_refresh(self, display, refresh_period): if refresh_period is None: - wire = needs - period = DEFAULT_REFRESH_MS + if not getattr(display, "needs_refresh", False): + return + period = int(getattr(display, "refresh_period_ms", 0) or DEFAULT_REFRESH_MS) else: - refresh_period = int(refresh_period) - wire = refresh_period > 0 - period = refresh_period if wire else DEFAULT_REFRESH_MS - if not wire: + period = int(refresh_period) + if period <= 0: + return + show = getattr(display, "show", None) + if not callable(show): return - def _show(timer_obj): + def _show(timer_obj, _display=display, _show=show): if self._refresh_paused: return - for display in self._displays: - if getattr(display, "needs_refresh", False) and callable( - getattr(display, "show", None) - ): - display.show(timer_obj) - - self._refresh_pending = True - arm = lambda: self._subscribe_refresh(_show, period) # noqa: E731 - if self._sync_refresh_needs_deferred_arm() and not self._timer_async: - # This provider wants the refresh subscription armed from inside the - # loop, so queue it unconditionally rather than asking _defer. - self._deferred.append(arm) - else: - self._defer(arm) + _show(timer_obj) - @staticmethod - def _sync_refresh_needs_deferred_arm(): - try: - from multimer import auto as timer - - return getattr(timer, "_defer_sync_arm", False) - except ImportError: - return False + name = "refresh:%s" % (getattr(display, "__class__", type(display)).__name__,) + tim = multimer.every(period, _show, name=name) + tim._display = display + self._refresh_timers.append(tim) - def _subscribe_refresh(self, show_fn, period): - self._refresh_pending = False - self._refresh_subscription = self.every(period, show_fn) + # -- service ---------------------------------------------------------- def _arm_service(self): - if self._service_subscription is not None or self._service_pending: + if self._service_timer is not None or not self.devices and not self._displays: return - self._service_pending = True - self._defer(self._subscribe_service) - - def _subscribe_service(self): - self._service_pending = False - self._service_subscription = self.every(SERVICE_TICK_MS, self._service_tick) + self._service_timer = multimer.every(SERVICE_TICK_MS, self._service_tick, name="app.service") def _service_tick(self, timer_obj): - if self._quit_requested or self._app_drives_poll: + if self._quit_requested or self._app_drives_poll or self._polling_claim is not None: return self._in_service_poll = True try: @@ -658,25 +476,17 @@ def _service_tick(self, timer_obj): self._in_service_poll = False def poll(self): - """Poll registered devices and dispatch any pending events.""" + """Poll registered devices and dispatch any pending events. + + A program that calls this from its own loop takes over from the + service timer; delivery of every other timer happens here too. + """ if not self._in_service_poll: self._app_drives_poll = True - try: - from multimer import run_deadline_hook - - run_deadline_hook() - except ImportError: - pass - try: - from multimer import auto as timer - - timer.pump() - except ImportError: - pass - self._flush_deferred() - + multimer.pump() + multimer.run_deadline_hook() eventlist = [] - for device in self.devices: + for device in tuple(self.devices): dev_events = device.poll() if dev_events: eventlist.extend(dev_events) @@ -689,86 +499,40 @@ def poll(self): cb(event) return eventlist - def arm_async_refresh(self): - """Deprecated alias for flushing deferred arming; prefer :meth:`on_start`. - - Kept because it is public API and callers may still invoke it from - inside a running loop. - """ - self._flush_deferred() - - async def _run_async(self, tick_ms=SERVICE_TICK_MS): - from multimer import asyncio - - self._flush_deferred() - self._blocking_run = True - try: - while not self._quit_requested: - await asyncio.sleep(tick_ms / 1000) - try: - from multimer import run_deadline_hook - - run_deadline_hook() - except ImportError: - pass - finally: - self._blocking_run = False - self._perform_teardown() + # -- lifecycle -------------------------------------------------------- def run(self, tick_ms=SERVICE_TICK_MS): - """Start the application and run until quit.""" - from multimer import auto as timer - - self._install_hostloop() - _hostloop.claim() + """Block until quit, delivering timers and events. Optional. - if self._timer_async: - if self._event_loop_running(): - self._flush_deferred() - return - from multimer import asyncio - - asyncio.run(self._run_async(tick_ms)) - self._raise_exit_code() - return - - # Nothing to block for when the host already runs a loop and the timer - # drives itself: an interactive REPL keeps the prompt, and a browser - # page would deadlock its own event loop if we slept here. - self_driving = getattr(timer, "uses_interrupts", False) or sys.platform in ( - "emscripten", - "webassembly", - ) - if _hostloop.strategy() == _hostloop.AMBIENT and self_driving: - self._flush_deferred() + Returns at once where the host already owns a loop that keeps the + program running (a REPL under ``-i``, a browser page, a notebook). + """ + if multimer.strategy() == _hostloop.AMBIENT: return - - self._flush_deferred() - - self._blocking_run = True + _hostloop.claim() try: - while not self._quit_requested: - timer.sleep_ms(tick_ms) + multimer.run_until(lambda: self._teardown_done, tick_ms) finally: - self._blocking_run = False - self._perform_teardown() + _hostloop.release() self._raise_exit_code() def run_async(self, coro_or_fn): - """Run an async coroutine or factory under the App's async environment.""" - from multimer import asyncio + """Run a coroutine (or a factory of one) under the host's asyncio loop. - if asyncio is None: + Schedules it as a task where a loop is already running (Jupyter, + PyScript) and returns the task; otherwise ``asyncio.run`` blocks. + """ + aio = multimer.asyncio + if aio is None: raise RuntimeError("asyncio is not available") async def runner(): - self._flush_deferred() coro = coro_or_fn() if callable(coro_or_fn) else coro_or_fn return await coro - if self._event_loop_running(): - return asyncio.create_task(runner()) - return asyncio.run(runner()) + if multimer.loop_running(): + return aio.create_task(runner()) + return aio.run(runner()) def request_quit(self, code=None): """Request a clean application shutdown.""" @@ -781,56 +545,11 @@ def _handle_quit(self): return self._quit_requested = True self._refresh_paused = True - self._pending_teardown = True - # Single quit choke point. Under ``exit_hook`` the loop notices via - # ``alive()``; under ``ambient`` nothing of ours would ever notice, so - # ``quit()`` performs teardown there. Both land on _teardown_from_loop. - _hostloop.quit() - if self._in_service_poll: - self._teardown_from_loop() + self._perform_teardown() def _teardown_from_loop(self): - """Tear down, deferring one turn if we are inside a callback.""" - if self._teardown_done: - return - if (self._in_service_poll or self._in_tick_dispatch) and self._schedule_async_teardown(): - return self._perform_teardown() - def _schedule_async_teardown(self): - """Defer teardown to the next loop turn. True when scheduled. - - Not gated on ``timer_async``: ``multimer.auto`` resolves to an async - provider in the browser even for an app that never asked for one, and - deinitialising an async-backed timer from inside its own callback fails - with "can't cancel self". - - ``create_task`` cannot answer "is a loop running?" -- MicroPython - happily creates a task with no loop running (verified on ESP32), which - would queue teardown onto a queue nothing services, so the app would - never tear down at all. ``loop_running()`` is the probe that answers - correctly on every interpreter. - """ - if not self._event_loop_running(): - return False - try: - from multimer import asyncio - - async def _later(): - await asyncio.sleep(0) - self._perform_teardown() - - asyncio.create_task(_later()) - return True - except Exception: - return False - - def _try_perform_teardown(self): - # Reached from inside _dispatch_tick, so it must take the deferring - # path: tearing down there cancels the very task/timer delivering the - # callback ("can't cancel self" on an AsyncTimer). - self._teardown_from_loop() - def _perform_teardown(self): if self._teardown_done: return @@ -838,13 +557,12 @@ def _perform_teardown(self): self._quit_requested = True if App._current is self: App._current = None - self._pending_teardown = False if self._before_quit is not None: try: self._before_quit() except Exception: pass - self.stop_timer() + self.stop_timers() for display in tuple(self._displays): if callable(getattr(display, "quit", None)): try: @@ -852,6 +570,8 @@ def _perform_teardown(self): except Exception: pass self._displays.clear() + # Nothing of ours is left to keep the process alive. + multimer.keepalive(False) def _raise_exit_code(self): code = self._exit_code diff --git a/lib/displaydev/__init__.py b/lib/displaydev/__init__.py index 6c5857c2..87365c63 100644 --- a/lib/displaydev/__init__.py +++ b/lib/displaydev/__init__.py @@ -413,6 +413,86 @@ def _blit_transparent_generic(blit_rect, buf, x, y, w, h, bpp, key): colstart += bpp +class FrameClock: + """A display's frame cadence as a subscription: ``subscribe(fn)`` calls + ``fn()`` once per frame, on the main thread, from ``multimer``. + + ``period_ms`` is the frame period the backend knows about; ``count`` and + ``last`` (``ticks_ms``) say what has been delivered; ``stop()`` releases + the underlying timer. A backend with a host frame signal (vsync, the + browser's animation frame) subclasses this and overrides ``_start`` / + ``_stop`` to deliver from that signal instead of a timer. + """ + + def __init__(self, period_ms, name=None): + self._period_ms = max(1, int(period_ms)) + self.name = name or "frame" + self.count = 0 + self.last = None + self._subs = [] + self._timer = None + + @property + def period_ms(self): + return self._period_ms + + @period_ms.setter + def period_ms(self, value): + value = max(1, int(value)) + if value == self._period_ms: + return + self._period_ms = value + if self._timer is not None: + self._stop() + self._start() + + @property + def running(self): + return self._timer is not None + + def subscribe(self, fn): + if not callable(fn): + raise ValueError("fn must be callable") + if fn not in self._subs: + self._subs.append(fn) + if self._timer is None: + self._start() + return fn + + def unsubscribe(self, fn): + try: + self._subs.remove(fn) + except ValueError: + pass + if not self._subs: + self.stop() + + def stop(self): + self._stop() + + def _start(self): + import multimer + + self._timer = multimer.every(self._period_ms, self._tick, name=self.name) + + def _stop(self): + t = self._timer + self._timer = None + if t is not None: + t.deinit() + + def _tick(self, _timer=None): + self.count += 1 + try: + from multimer import ticks_ms + + self.last = ticks_ms() + except ImportError: + pass + for fn in tuple(self._subs): + fn() + + class DisplayDriver: """ Base class for all display backends (BusDisplay, SDLDisplay, PGDisplay, FBDisplay, etc.). @@ -432,13 +512,31 @@ class DisplayDriver: """ needs_refresh = False - # True on async-native hosts (PSDisplay / JNDisplay); desktop PG/SDL keep False. - # Board configs decide appdev.App.timer_async via env_bool(..., display.requires_async_timer). - requires_async_timer = False + # The display's own frame period: what ``appdev.App`` presents at and what a + # GUI's refresh timer is set to. A backend that can measure its host's + # refresh (vsync, requestAnimationFrame) sets it; 33 ms otherwise. + refresh_period_ms = 33 share_framebuffer = False # HostEventsDevice reads this ``(key, mod)`` tuple; None disables keyboard quit. quit_chord = None + @property + def frame_clock(self): + """This display's :class:`FrameClock`: "call me once per frame". + + Created on first use. Backends with a real frame signal override + ``_make_frame_clock``; the default is a ``multimer`` timer at + :attr:`refresh_period_ms`. + """ + fc = getattr(self, "_frame_clock", None) + if fc is None: + fc = self._make_frame_clock() + self._frame_clock = fc + return fc + + def _make_frame_clock(self): + return FrameClock(self.refresh_period_ms, name="frame:%s" % (self.__class__.__name__,)) + def framebuffers(self): """Return panel buffers for direct GUI paint, or ``None``. diff --git a/lib/displaydev/auto.py b/lib/displaydev/auto.py index f8f8eff6..6e0b6d6a 100644 --- a/lib/displaydev/auto.py +++ b/lib/displaydev/auto.py @@ -10,7 +10,7 @@ directly; this factory is convenience only. Returns the display driver directly. Desktop drivers expose ``get_events`` for -``appdev.App(host_read=...)`` and ``requires_async_timer`` for the timer default. +``appdev.App(host_read=...)``. """ import sys @@ -61,8 +61,7 @@ def AutoDisplay( Returns: A ``PSDisplay``, ``JNDisplay``, ``WinDisplay``, ``PGDisplay``, or - ``SDLDisplay`` with ``get_events`` and ``requires_async_timer`` set for - board_config wiring. + ``SDLDisplay`` with ``get_events`` set for board_config wiring. """ from displaydev import env_get if canvas_id is None: diff --git a/lib/displaydev/jndisplay.py b/lib/displaydev/jndisplay.py index 09117ccf..1649b27b 100644 --- a/lib/displaydev/jndisplay.py +++ b/lib/displaydev/jndisplay.py @@ -323,7 +323,6 @@ class JNDisplay(DesktopDisplay): """ needs_refresh = True - requires_async_timer = True quit_chord = (keys.K_AC_BACK, 0) _next_display_id = 0 diff --git a/lib/displaydev/psdisplay.py b/lib/displaydev/psdisplay.py index aa4e64f9..23657176 100644 --- a/lib/displaydev/psdisplay.py +++ b/lib/displaydev/psdisplay.py @@ -356,7 +356,6 @@ class PSDisplay(DesktopDisplay): """ needs_refresh = True - requires_async_timer = True quit_chord = (keys.K_AC_BACK, 0) def __init__(self, id, width=None, height=None, *, quiet=False): diff --git a/lib/displaydev/wasmdisplay.py b/lib/displaydev/wasmdisplay.py index fd2f0949..4eb7f578 100644 --- a/lib/displaydev/wasmdisplay.py +++ b/lib/displaydev/wasmdisplay.py @@ -252,14 +252,12 @@ class WasmDisplay(DesktopDisplay, FBDisplay): so draws that never call ``show()`` explicitly (e.g. a scroll timer) still reach the composited front buffer once double- buffered. - requires_async_timer (bool): True — single-threaded cooperative browser - WASM, like :class:`PSDisplay`/:class:`JNDisplay`: without the - async-driven host loop, ``app.every()`` timers (and the - ``needs_refresh`` timer above) never fire. """ needs_refresh = True - requires_async_timer = True + # The browser scans the framebuffer each animation frame; 60 Hz is the + # common case and the bridge does not yet report the real rate. + refresh_period_ms = 16 quit_chord = (keys.K_AC_BACK, 0) def __init__( diff --git a/lib/displaydev/windisplay.py b/lib/displaydev/windisplay.py index 27adbbca..22fc6e90 100644 --- a/lib/displaydev/windisplay.py +++ b/lib/displaydev/windisplay.py @@ -244,7 +244,6 @@ class WinDisplay(DesktopDisplay): """ needs_refresh = True - requires_async_timer = False quit_chord = (keys.K_q, keys.KMOD_CTRL) # Defaults at class level so teardown works on a half-built instance: diff --git a/lib/multimer/README.md b/lib/multimer/README.md index 53b8321b..2e620387 100644 --- a/lib/multimer/README.md +++ b/lib/multimer/README.md @@ -1,7 +1,8 @@ # multimer -Cross-platform `machine.Timer`-style providers, `AsyncTimer`, millisecond ticks, -and scheduling helpers for MicroPython, CircuitPython, and CPython. +One `machine.Timer`-shaped `Timer`, one clock, and one dispatcher, on every +interpreter PyDevices runs on: CPython, MicroPython (boards, unix, windows, +wasm) and CircuitPython. Canonical source: [pydevices/lib/multimer](https://github.com/PyDevices/pydevices/tree/main/lib/multimer). @@ -29,31 +30,23 @@ mip.install("pydevices", index="https://PyDevices.github.io/mip") ## Quick start -Choose a timer provider explicitly: - ```python -from multimer import machine as timer - -tim = timer.Timer(-1) -tim.init(mode=timer.Timer.PERIODIC, period=500, callback=lambda t: print("tick")) +import multimer +from multimer import Timer -while True: - timer.sleep_ms(1000) -``` +tim = Timer(-1) +tim.init(mode=Timer.PERIODIC, period=500, callback=lambda t: print("tick")) -Or let the host decide — the only change is the import: - -```python -from multimer import auto as timer +multimer.sleep_ms(3000) # or end the script and look at it from >>> +multimer.report() ``` -Explicit providers are `machine`, `librt`, `win32`, `sdl2`, `threading`, and -`polling`; each exposes the same surface. Importing `multimer` itself probes -nothing and gives you `AsyncTimer`, the `ticks_*` helpers, and `schedule`. +Callbacks run on the main thread at a safe point on every host; the script +can end and the timers keep firing at the prompt. Importing `multimer` +touches nothing until the first timer is armed. -**Everything else — the provider-selection order, the interpreter matrix, async -timers, `pump()` / `sleep_ms()`, hard versus soft delivery, and the -`MULTIMER_BACKEND` override — is in +**Everything else — the wake sources per host, `hold()`, `schedule()`, +`keepalive`, `repl()` for hosts with no prompt, and the introspection — is in [docs/multimer.md](https://github.com/PyDevices/pydevices/blob/main/docs/multimer.md).** ## Links diff --git a/lib/multimer/__init__.py b/lib/multimer/__init__.py index 6db6a80a..44be3d3d 100644 --- a/lib/multimer/__init__.py +++ b/lib/multimer/__init__.py @@ -1,29 +1,44 @@ # SPDX-FileCopyrightText: 2024 Brad Barnett # # SPDX-License-Identifier: MIT -"""Backend-neutral timing primitives for MicroPython and Python hosts. +"""multimer: one Timer, one clock, one dispatcher, on every PyDevices host. -Importing :mod:`multimer` never selects a synchronous timer backend. Choose a -provider explicitly, or opt into host selection through :mod:`multimer.auto`:: +:: import multimer - from multimer import auto as timer - - tim = timer.Timer(-1) - tim.init(period=100, callback=on_tick) - timer.sleep_ms(10) - -Backend-neutral clock, scheduling, and asyncio helpers remain available from -this package root. + from multimer import Timer + + tim = Timer(-1) + tim.init(mode=Timer.PERIODIC, period=10, callback=on_tick) + sub = multimer.every(33, draw) + multimer.report() # what is running, from the REPL + +Callbacks run on the main thread at a safe point: between two bytecodes +where the host can interrupt (a board, a signal, a pending call), otherwise +at the next idle point (``sleep_ms``, ``pump``, the REPL waiting for a key, an +await). They never run on another thread and never inside a C call. Importing +this package does nothing to the host; the wake source is chosen when the +first timer is armed. See ``docs/multimer.md``. """ import sys -from ._async_timer import AsyncTimer from ._asyncio_loader import load_asyncio, loop_running -from ._schedule import _run_pending, schedule +from ._dispatch import ( + Timer, + alive, + hold, + info, + keepalive, + pump, + run_until, + schedule, + sleep_ms, + stop_all, + timers, +) +from ._dispatch import source as _source from ._ticks import ( - _raw_sleep_ms, monotonic, run_deadline_hook, set_deadline_hook, @@ -31,95 +46,123 @@ ticks_diff, ticks_less, ticks_ms, + ticks_us, ) -_PROVIDER_MODULES = ( - "auto", - "librt", - "machine", - "polling", - "sdl2", - "threading", - "wasm", - "win32", -) +__version__ = "0.2.0" -def _provider_pump(drain=None): - """Drain scheduled work and an optional provider event queue.""" - _run_pending() - if drain is not None: - drain() +def every(ms, callback, *, name=None): + """A PERIODIC :class:`Timer` firing ``callback(timer)`` every *ms*.""" + return Timer(-1, mode=Timer.PERIODIC, period=ms, callback=callback, name=name) -def _provider_sleep_ms(ms, *, backend_sleep=None, drain=None, uses_interrupts=False): - """Sleep using one provider's unchanged signal/pump behavior.""" - run_deadline_hook() - if not uses_interrupts: - _provider_pump(drain) - if backend_sleep is not None: - backend_sleep(ms) - else: - _raw_sleep_ms(ms) - run_deadline_hook() - if not uses_interrupts: - _provider_pump(drain) +def after(ms, callback, *, name=None): + """A ONE_SHOT :class:`Timer` firing ``callback(timer)`` once, after *ms*.""" + return Timer(-1, mode=Timer.ONE_SHOT, period=ms, callback=callback, name=name) -async def _async_sleep_ms(ms): - """Yield for *ms* through the selected asyncio implementation.""" +async def asleep_ms(ms): + """Coroutine sleep for async code; delivers due timers on hosts without a wake source.""" aio = load_asyncio() if aio is None: - raise ImportError("async sleep requires asyncio, uasyncio, or _asyncio") + raise ImportError("asleep_ms needs asyncio") sleep = getattr(aio, "sleep_ms", None) if sleep is not None: await sleep(ms) else: await aio.sleep(ms / 1000) + pump() run_deadline_hook() -def _async_only_interpreter(): - """True on hosts whose application lifecycle is owned by an async loop.""" - if sys.platform in ("emscripten", "webassembly"): - return True - try: - import pyscript # noqa: F401 - - return True - except Exception: - pass - try: - get_ipython() # noqa: F821 - return True - except Exception: - return False +def strategy(): + """How the program stays alive past the script body: ambient, exit_hook, none, or None.""" + from . import _hostloop + + return _hostloop.strategy() + + +def repl(namespace=None, prompt=">>> ", tick_ms=10): + """A line REPL that keeps delivering timers while it reads (hosts with no prompt).""" + from ._repl import repl as _repl + + return _repl(namespace, prompt, tick_ms) + + +def report(file=None): + """Print the dispatcher's state and every live timer, for a person at a prompt.""" + out = file if file is not None else sys.stdout + d = info() + out.write( + "multimer on %s: source=%s delivery=%s wakes_blocking=%s strategy=%s\n" + % (d["host"], d["source"], d["delivery"], d["wakes_blocking"], strategy()) + ) + out.write( + " deliveries=%d wakes=%d max_gap=%d ms errors=%d sched_full=%d held=%d keepalive=%s next=%s ms\n" + % ( + d["deliveries"], + d["wakes"], + d["max_gap_ms"], + d["errors"], + d["sched_full"], + d["held"], + d["keepalive"], + d["next_ms"], + ) + ) + if "source_error" in d: + out.write(" source fell back to none: %s\n" % d["source_error"]) + ts = timers() + if not ts: + out.write(" no timers armed\n") + for t in ts: + out.write(" %r due_in=%s ms\n" % (t, t.due_in)) __all__ = [ - "AsyncTimer", - "asyncio", + "Timer", + "after", + "alive", + "asleep_ms", + "every", + "hold", + "info", + "keepalive", "loop_running", "monotonic", + "pump", + "repl", + "report", "run_deadline_hook", + "run_until", "schedule", "set_deadline_hook", + "sleep_ms", + "stop_all", + "strategy", "ticks_add", "ticks_diff", "ticks_less", "ticks_ms", + "ticks_us", + "timers", ] +_SUBMODULES = ("_dispatch", "_hostloop", "_inputhook", "_repl", "_ticks", "_asyncio_loader") + + def __getattr__(name): if name == "asyncio": return load_asyncio() - # MicroPython resolves ``from multimer import polling`` through package - # ``__getattr__`` and does not perform CPython's automatic submodule - # fallback afterward. Import only the explicitly requested provider here; - # a plain ``import multimer`` still loads none of them. - if name in _PROVIDER_MODULES: - module = __import__(f"{__name__}.{name}", None, None, (name,)) + if name == "source": + s = _source() + return None if s is None else s.name + # MicroPython resolves ``from . import _hostloop`` through the package's + # __getattr__ and does not fall back to importing the submodule itself. + if name in _SUBMODULES: + module = __import__(__name__ + "." + name, None, None, (name,)) globals()[name] = module return module - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + raise AttributeError("module %r has no attribute %r" % (__name__, name)) diff --git a/lib/multimer/_async_timer.py b/lib/multimer/_async_timer.py deleted file mode 100644 index 508113c8..00000000 --- a/lib/multimer/_async_timer.py +++ /dev/null @@ -1,93 +0,0 @@ -# SPDX-FileCopyrightText: 2024 Brad Barnett -# -# SPDX-License-Identifier: MIT -"""asyncio-backed Timer with machine.Timer-compatible API.""" - -import sys - -from ._asyncio_loader import load_asyncio, loop_running -from ._core import _TimerCore - - -def _require_asyncio(): - aio = load_asyncio() - if aio is None: - raise ImportError("AsyncTimer requires asyncio, uasyncio, or _asyncio") - return aio - - -def _may_arm_async_timer(): - """True when :meth:`AsyncTimer.init` is allowed to create a task. - - Browser hosts own the loop for the whole program (including import). - Everywhere else use :func:`loop_running` — not ``get_running_loop`` / - ``get_event_loop``, which mislead on MicroPython and CircuitPython. - """ - if sys.platform in ("emscripten", "webassembly"): - return True - return loop_running() - - -class AsyncTimer(_TimerCore): - """``asyncio``-backed timer with the same API as ``machine.Timer`` / :class:`Timer`. - - Use when ``app.timer_async`` is True (PyScript, Jupyter, desktop async). - :meth:`init` requires a running event loop — prefer constructing at import - time and calling :meth:`init` (or passing kwargs) only after the loop starts, - or let ``appdev.App`` defer arming via ``arm_async_refresh``. - - Inherited: :attr:`ONE_SHOT`, :attr:`PERIODIC`, :meth:`init`, :meth:`deinit`. - """ - - def __init__(self, id=-1, **kwargs): - """Create an async timer, optionally calling :meth:`init` when kwargs are given. - - Args: - id: Timer id (kept for API parity; async tasks are not hardware-bound). - **kwargs: Forwarded to :meth:`init` when non-empty. - - Raises: - ImportError: No ``asyncio`` / ``uasyncio`` available. - RuntimeError: :meth:`init` called with no running event loop. - """ - self._running = False - self._task = None - super().__init__(id, **kwargs) - - def _wait_idle(self): - pass - - def _arm(self): - aio = _require_asyncio() - if not _may_arm_async_timer(): - raise RuntimeError("AsyncTimer.init requires a running event loop") - self._running = True - self._task = aio.create_task(self._loop()) - - def _disarm(self): - self._running = False - task = self._task - self._task = None - if task is not None: - task.cancel() - - async def _loop(self): - aio = _require_asyncio() - cancelled = aio.CancelledError - sleep = getattr(aio, "sleep_ms", None) - try: - while self._running: - if sleep is not None: - await sleep(self._period_ms) - else: - await aio.sleep(self._period_ms / 1000) - if not self._running: - break - self._deliver() - if self._mode == self.ONE_SHOT or not self._armed: - self._running = False - break - except cancelled: - pass - finally: - self._busy = False diff --git a/lib/multimer/_core.py b/lib/multimer/_core.py deleted file mode 100644 index 54143812..00000000 --- a/lib/multimer/_core.py +++ /dev/null @@ -1,241 +0,0 @@ -# SPDX-FileCopyrightText: 2024 Brad Barnett -# -# SPDX-License-Identifier: MIT -"""Shared machine.Timer-compatible core (internal).""" - -try: - from micropython import const -except ImportError: - - def const(x): - return x - - -from ._schedule import schedule -from ._ticks import _raw_sleep_ms as _sleep_ms -from ._ticks import ticks_diff, ticks_ms - - -class _TimerCore: - """Internal base matching MicroPython ``machine.Timer`` semantics. - - Public ``Timer`` / ``AsyncTimer`` inherit this API. Documented members are - exposed on those classes via mkdocstrings ``inherited_members``. - - Attributes: - ONE_SHOT: Mode constant — fire once then disarm (value ``0``). - PERIODIC: Mode constant — repeat until :meth:`deinit` (value ``1``). - """ - - ONE_SHOT = const(0) - """Fire once then disarm.""" - - PERIODIC = const(1) - """Repeat until :meth:`deinit`.""" - - def __init__(self, id=-1, **kwargs): - """Create a timer, optionally calling :meth:`init` when kwargs are given. - - Args: - id: Virtual or hardware timer id (``-1`` = auto-allocate when supported). - **kwargs: Forwarded to :meth:`init` when non-empty - (``mode``, ``freq``, ``period``, ``callback``, ``hard``). - """ - self.id = id - self._mode = None - self._period_ms = 0 - self._callback = None - self._hard = True - self._busy = False - # True only while this thread is inside this timer's own callback. - # Distinguishes "busy elsewhere" (wait) from "busy because I am the - # delivery" (must not wait -- see _wait_idle). - self._delivering = False - self._armed = False - # Soft path: at most one ``schedule`` entry (or in-flight callback) so a - # slow tick cannot flood ``micropython.schedule`` under librt signals. - self._sched_pending = False - # Wall time of last soft callback completion — used to drop RT-signal - # backlog so a slow tick cannot busy-loop catch-up frames (MP -i + LVGL). - self._soft_done_ms = None - # Minimum idle after the last soft callback before another may schedule. - # Grown to the callback duration under overload so duty cycle stays - # ≤ ~50% and an interactive REPL still gets stdin time. - self._soft_gap_ms = 0 - # Pre-bind for the soft (scheduled) path: evaluating ``self._invoke_callback`` - # allocates a bound method, which fails inside a locked-heap ISR/FFI - # callback. Bind once here so scheduling touches only stored references. - self._deliver_cb = self._soft_invoke - if kwargs: - self.init(**kwargs) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - self.deinit() - return False - - def init(self, *, mode=PERIODIC, freq=-1, period=-1, callback=None, hard=True): - """Arm or re-arm the timer with MicroPython ``machine.Timer`` semantics. - - Args: - mode: :attr:`ONE_SHOT` or :attr:`PERIODIC` (default ``PERIODIC``). - freq: Frequency in Hz. When ``freq > 0``, period is ``1000 // freq`` ms. - period: Period in milliseconds (used when ``freq`` is not positive). - callback: Callable invoked as ``callback(timer)`` on each fire. - hard: When ``True``, call ``callback`` directly from the backend - delivery path. When ``False``, deliver via :func:`schedule`. - Soft still applies coalesce and inter-tick gap. On signal - providers (``uses_interrupts`` — librt, ``machine.Timer``), - delivery is already on the main thread, so soft invokes the - callback immediately there (≈ hard for *when* it runs). Soft - only defers to a later main-thread drain when the backend - delivers off-main (threading / polling). The SDL2 backend - always invokes on the VM thread (usdl2 already marshalled - there) and skips a second soft ``schedule`` hop. On - MicroPython, soft uses ``micropython.schedule`` (queue out - of a locked-heap ISR). - - Raises: - ValueError: Invalid ``mode``, or neither ``freq`` nor ``period`` - yields a period of at least 1 ms. - """ - if mode not in (self.ONE_SHOT, self.PERIODIC): - raise ValueError("Invalid timer mode") - - if self._armed: - self._disarm() - - period_ms = int(1000 / freq) if freq > 0 else period - - if period_ms < 1: - raise ValueError("Invalid freq or period") - - self._mode = mode - self._period_ms = period_ms - self._callback = callback - self._hard = hard - self._sched_pending = False - self._soft_done_ms = None - self._soft_gap_ms = period_ms - self._arm() - self._armed = True - - def deinit(self): - """Stop the timer and clear mode, period, and callback. - - Waits until any in-flight callback finishes, then disarms the backend. - Safe to call repeatedly. - """ - self._wait_idle() - if self._armed: - self._disarm() - self._armed = False - self._mode = None - self._period_ms = 0 - self._callback = None - self._hard = True - self._sched_pending = False - self._soft_done_ms = None - self._soft_gap_ms = 0 - - def _wait_idle(self): - # deinit() called from inside this timer's own callback must not wait: - # _deliver() holds _busy for the duration of that callback, so spinning - # here would deadlock the delivering thread against itself. machine.Timer - # allows self-deinit from an ISR, so the software providers must too. - if self._delivering: - return - while self._busy: - _sleep_ms(1) - - def _invoke_callback(self, arg): - cb = self._callback - # A soft (scheduled) delivery can outlive its timer: deinit() clears the - # callback while a schedule(_deliver_cb) is still queued (seen on the - # CircuitPython threading backend during teardown). Skip the stale - # delivery instead of crashing on the now-None callback. - if cb is None: - return - cb(arg) - - def _soft_invoke(self, arg): - # Keep ``_sched_pending`` set for the whole callback so overlapping - # timer signals coalesce instead of enqueueing more schedule entries. - t0 = ticks_ms() - try: - self._invoke_callback(arg) - finally: - self._sched_pending = False - done = ticks_ms() - self._soft_done_ms = done - duration = ticks_diff(done, t0) - gap = self._period_ms - gap = max(gap, duration) - # Clamp so a ticks glitch cannot disable soft delivery forever - # (that left RT signals interrupting sleep with no callback work — - # rising CPU + dead heartbeats under micropython -i). - max_gap = self._period_ms * 50 if self._period_ms else 500 - max_gap = max(max_gap, 100) - max_gap = min(max_gap, 2000) - gap = min(gap, max_gap) - self._soft_gap_ms = gap - # ONE_SHOT cleanup must not run in the librt/FFI signal path - # (heap locked on MicroPython → MemoryError in timer_settime). - if self._mode == self.ONE_SHOT: - self._deinit_oneshot_safe() - - def _deinit_oneshot_safe(self): - """Disarm a fired ONE_SHOT; absorb heap-locked failures on signal path.""" - try: - self.deinit() - except MemoryError: - # Kernel oneshot already has a zero interval; leak the timer id - # until process exit rather than allocating under a locked heap. - pass - - def _deliver(self): - if self._busy: - return - - if not self._hard: - if self._sched_pending: - # Already queued or running — drop this tick under load. - return - # Drop queued RT signals that piled up during a slow callback so we - # do not immediately schedule another frame (hard lock under -i). - done = self._soft_done_ms - gap = self._soft_gap_ms if self._soft_gap_ms else self._period_ms - if done is not None and gap > 0 and ticks_diff(ticks_ms(), done) < gap: - return - - self._busy = True - self._delivering = True - try: - if self._hard: - self._invoke_callback(self) - else: - self._sched_pending = True - try: - schedule(self._deliver_cb, self) - except RuntimeError: - # ``schedule queue full`` — drop this tick; next signal retries. - self._sched_pending = False - finally: - self._delivering = False - self._busy = False - - if self._mode == self.ONE_SHOT: - if self._hard: - # May still be heap-locked on librt+MP FFI; absorb if so. - self._deinit_oneshot_safe() - # Soft: ``_soft_invoke`` deinits after the scheduled callback. - return 0 - return self._period_ms - - def _arm(self): - raise NotImplementedError - - def _disarm(self): - raise NotImplementedError diff --git a/lib/multimer/_dispatch.py b/lib/multimer/_dispatch.py new file mode 100644 index 00000000..e2c40dee --- /dev/null +++ b/lib/multimer/_dispatch.py @@ -0,0 +1,642 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The dispatcher: every Timer is a deadline here; a wake source says when to look. + +One list of armed timers. :func:`deliver` runs whatever is due, on the main +thread, at a safe point, and re-arms the host's wake source to the earliest +deadline left. Idle points (``sleep_ms``, ``pump``, the REPL's input hook, +the exit-hook loop) call the same :func:`deliver`, so a host with no wake +source still delivers, just later. + +Rules the dispatcher keeps for every timer: + +* a callback never interrupts another callback (a wake that arrives while + one runs is answered after it returns); +* a timer that is still running when its slot comes is not re-entered; the + slot is skipped and counted in ``missed``; +* after a callback runs longer than its period, its next slot is no sooner + than ``min(overrun, yield_cap)`` later, so a slow pass lowers its rate + instead of taking the thread (lvgl-bindings#15 and #19, for every timer); +* deadlines are absolute (``due += period``), so delivery latency never + drifts into the schedule; +* a raising callback is printed once and keeps its schedule. +""" + +import sys + +from ._ticks import _raw_sleep_ms, ticks_add, ticks_diff, ticks_ms + +ONE_SHOT = 0 +PERIODIC = 1 + +_IS_MP = sys.implementation.name == "micropython" + +_timers = [] +_held = 0 +_in_deliver = False +_source = None +_source_error = None +_keepalive = False +_stats = { + "deliveries": 0, + "max_gap_ms": 0, + "last_ms": None, + "errors": 0, + "sched_full": 0, + "wakes": 0, +} + +# The portable schedule() queue, for hosts whose interpreter has no +# micropython.schedule. Entries are (fn, arg). +_scheduled = [] +_sched_lock = None +try: + import _thread + + _sched_lock = _thread.allocate_lock() + _main_ident = _thread.get_ident() + + def _on_main_thread(): + return _thread.get_ident() == _main_ident + +except ImportError: + + def _on_main_thread(): + return True + + +try: + from micropython import schedule as _mp_schedule +except ImportError: + _mp_schedule = None + +# One entry from us in micropython.schedule's queue at a time. +_sched_pending = [False] + + +def _deliver_scheduled(_arg): + _sched_pending[0] = False + deliver() + + +def wake_from_source(safe=False): + """What a wake source calls when its deadline passes. + + On MicroPython a source in an interrupt or signal context passes + ``safe=False``: the callback must run at a bytecode boundary, so this only + queues one ``micropython.schedule`` entry. A source whose callback the + port already delivers through ``micropython.schedule`` (``machine``, + ``native``) passes ``safe=True`` and delivery runs here, without a second + trip through the scheduler queue. Elsewhere the source is already at a + safe point (a Python signal handler, a pending call, an asyncio callback) + and delivery runs here. + """ + _stats["wakes"] += 1 + if _mp_schedule is not None and not safe: + if _sched_pending[0]: + return + _sched_pending[0] = True + try: + _mp_schedule(_deliver_scheduled, None) + except RuntimeError: + _sched_pending[0] = False + _stats["sched_full"] += 1 + return + deliver() + + +def deliver(): + """Run every due callback; return ms until the next deadline, or None. + + Returns None without delivering when called re-entrantly (from inside a + callback) or while a :func:`hold` is active; the outer delivery, or the + end of the hold, picks the work up. + """ + global _in_deliver + if _in_deliver: + return None + if _held: + return None + _in_deliver = True + try: + _run_scheduled() + now = ticks_ms() + last = _stats["last_ms"] + if last is not None: + gap = ticks_diff(now, last) + if gap > _stats["max_gap_ms"]: + _stats["max_gap_ms"] = gap + _stats["last_ms"] = now + _stats["deliveries"] += 1 + # Bounded: a callback that arms a zero-period timer must not spin here. + for _ in range(64): + due = None + for t in _timers: + if not t._running and ticks_diff(t._due, now) <= 0: + if due is None or ticks_diff(t._due, due._due) < 0: + due = t + if due is None: + break + due._fire(now) + now = ticks_ms() + _run_scheduled() + _stats["last_ms"] = now + finally: + _in_deliver = False + return _arm_next() + + +def next_delay_ms(): + """ms until the earliest deadline, 0 when something is due, None when idle.""" + if not _timers: + return None + now = ticks_ms() + best = None + for t in _timers: + d = ticks_diff(t._due, now) + if best is None or d < best: + best = d + return best if best > 0 else 0 + + +def _arm_next(): + delay = next_delay_ms() + src = _source + if src is None: + return delay + if delay is None: + src.cancel() + else: + src.arm(delay) + return delay + + +def _run_scheduled(): + if not _scheduled: + return + while True: + if _sched_lock is not None: + _sched_lock.acquire() + try: + if not _scheduled: + return + fn, arg = _scheduled.pop(0) + finally: + if _sched_lock is not None: + _sched_lock.release() + fn(arg) + + +def schedule(fn, arg): + """Run ``fn(arg)`` at the next safe point (``micropython.schedule``'s shape). + + On MicroPython this is ``micropython.schedule`` itself. Elsewhere the call + is queued and runs at the next delivery, which is asked for now: a + bytecode host answers between the caller's next two bytecodes, an idle + host at its next idle point. Callable from any thread. + """ + if _mp_schedule is not None: + _mp_schedule(fn, arg) + return + if _sched_lock is not None: + _sched_lock.acquire() + try: + _scheduled.append((fn, arg)) + finally: + if _sched_lock is not None: + _sched_lock.release() + src = _source + if src is not None: + src.arm(0) + + +def _print_exception(exc): + pe = getattr(sys, "print_exception", None) + if pe is not None: + pe(exc) + return + import traceback + + traceback.print_exception(type(exc), exc, exc.__traceback__) + + +class Timer: + """A ``machine.Timer``-shaped timer delivered by the dispatcher. + + ``Timer(id=-1)`` then :meth:`init`, or keyword arguments straight to the + constructor as on a board. Attributes are for looking at from the REPL:: + + >>> tim + Timer(name='lvgl', period=10, PERIODIC, fired=1203, missed=2, late_max=3 ms) + """ + + ONE_SHOT = ONE_SHOT + PERIODIC = PERIODIC + + # The most a slow callback holds its own next slot back (ms). 0 keeps the + # absolute grid only. + yield_cap = 100 + + def __init__(self, id=-1, **kwargs): + self.id = id + self.name = kwargs.pop("name", None) + self._mode = None + self._period = 0 + self._callback = None + self._hard = False + self._due = 0 + self._armed = False + self._running = False + self._rescheduled = False + self.fired = 0 + self.missed = 0 + self.late_max = 0 + self.last = None + self.error = None + self._errors = 0 + if kwargs: + self.init(**kwargs) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + self.deinit() + return False + + def __repr__(self): + mode = "PERIODIC" if self._mode == PERIODIC else ("ONE_SHOT" if self._mode == ONE_SHOT else "off") + name = "" if self.name is None else "name=%r, " % (self.name,) + return "Timer(%speriod=%d, %s, fired=%d, missed=%d, late_max=%d ms%s)" % ( + name, + self._period, + mode, + self.fired, + self.missed, + self.late_max, + "" if self.error is None else ", error=%r" % (self.error,), + ) + + # -- machine.Timer API ------------------------------------------------- + + def init(self, *, mode=PERIODIC, freq=-1, period=-1, callback=None, hard=False): + """Arm or re-arm. ``freq`` in Hz wins over ``period`` in ms when positive. + + ``hard`` is accepted for ``machine.Timer`` parity; every host here + delivers soft (between bytecodes or at an idle point), which is what + ``hard=False`` means on a board. + """ + if mode not in (ONE_SHOT, PERIODIC): + raise ValueError("Invalid timer mode") + period_ms = int(1000 / freq) if freq > 0 else int(period) + if period_ms < 1: + raise ValueError("Invalid freq or period") + if callback is not None and not callable(callback): + raise ValueError("callback must be callable") + self._disarm() + self._mode = mode + self._period = period_ms + self._callback = callback + self._hard = bool(hard) + self._due = ticks_add(ticks_ms(), period_ms) + _arm(self) + + def deinit(self): + """Stop the timer. Safe to call twice, and from inside its own callback.""" + self._disarm() + self._mode = None + self._callback = None + + cancel = deinit + + def reschedule(self, delay_ms): + """Move the next delivery to *delay_ms* from now (allocation-free). + + For a ONE_SHOT timer this re-arms it; for a PERIODIC one it shifts + the grid. Callable from the timer's own callback, which is how a + self-pacing loop (LVGL's ``timer_handler`` asking to be called back + in N ms) runs on one Timer instead of a new one per pass. + """ + if self._mode is None: + raise ValueError("timer is not initialised") + ms = int(delay_ms) + if ms < 0: + ms = 0 + self._due = ticks_add(ticks_ms(), ms) + self._rescheduled = True + if not self._armed: + _arm(self) + else: + _arm_next() + + # -- introspection --------------------------------------------------- + + @property + def period(self): + return self._period + + @property + def mode(self): + return self._mode + + @property + def callback(self): + return self._callback + + @property + def running(self): + """True while armed (the callback may or may not be executing now).""" + return self._armed + + @property + def due_in(self): + """ms until the next delivery, or None when not armed.""" + if not self._armed: + return None + return ticks_diff(self._due, ticks_ms()) + + # -- internals ------------------------------------------------------- + + def _disarm(self): + if self._armed: + self._armed = False + try: + _timers.remove(self) + except ValueError: + pass + _arm_next() + + def _fire(self, now): + self._running = True + self._rescheduled = False + t0 = now + late = ticks_diff(t0, self._due) + if late > self.late_max: + self.late_max = late + cb = self._callback + try: + if cb is not None: + try: + cb(self) + except Exception as exc: # KeyboardInterrupt and SystemExit propagate + self.error = exc + self._errors += 1 + _stats["errors"] += 1 + if self._errors == 1: + _print_exception(exc) + finally: + self._running = False + end = ticks_ms() + self.fired += 1 + self.last = end + if not self._armed: + # deinit() from inside the callback, or a ONE_SHOT already retired. + return + if self._rescheduled: + # reschedule() from inside the callback chose the next deadline. + return + if self._mode == ONE_SHOT: + self._disarm() + return + period = self._period + nxt = ticks_add(self._due, period) + if ticks_diff(nxt, end) <= 0: + # Overran into the next slot. Never burst to catch up: step the + # grid past now, and after a pass longer than a period hold off + # for as long as the pass took, capped. + while ticks_diff(nxt, end) <= 0: + nxt = ticks_add(nxt, period) + self.missed += 1 + took = ticks_diff(end, t0) + if took > period and self.yield_cap > 0: + earliest = ticks_add(end, min(took, self.yield_cap)) + while ticks_diff(nxt, earliest) < 0: + nxt = ticks_add(nxt, period) + self.missed += 1 + self._due = nxt + + +def _arm(timer): + _ensure_source() + if timer not in _timers: + _timers.append(timer) + timer._armed = True + _arm_next() + from . import _hostloop + + _hostloop.ensure_installed() + + +def hold(): + """Context manager: no callback is delivered inside; due work runs at exit.""" + return _Hold() + + +class _Hold: + def __enter__(self): + global _held + _held += 1 + return self + + def __exit__(self, exc_type, exc, tb): + global _held + _held -= 1 + if _held == 0: + deliver() + return False + + +def sleep_ms(ms): + """Sleep for *ms*, delivering timers that come due on the way. + + Works on every host: a wake source that interrupts sleeps delivers + during them, and a host without one is served between the slices this + loop sleeps in. + """ + end = ticks_add(ticks_ms(), max(0, int(ms))) + while True: + wait = deliver() + rem = ticks_diff(end, ticks_ms()) + if rem <= 0: + return + chunk = rem if wait is None else min(rem, wait) + if chunk > 0: + _host_sleep_ms(chunk) + + +def _host_sleep_ms(ms): + src = _source + if src is not None: + s = getattr(src, "sleep_ms", None) + if s is not None: + s(ms) + return + _raw_sleep_ms(ms) + + +def pump(): + """Deliver what is due now. Returns ms until the next deadline, or None.""" + return deliver() + + +def run_until(pred, tick_ms=10): + """Block, delivering, until ``pred()`` is true.""" + while not pred(): + sleep_ms(tick_ms) + + +def keepalive(flag=True): + """Keep the process alive past the script's end while timers are armed. + + ``appdev.App`` sets this. A bare timer script behaves like a daemon + thread and lets the interpreter exit unless it asks. + """ + global _keepalive + _keepalive = bool(flag) + if _keepalive: + from . import _hostloop + + _hostloop.ensure_installed() + + +def alive(): + """True while the exit-hook loop should keep the process running.""" + return _keepalive and bool(_timers) + + +def timers(): + return tuple(_timers) + + +def stop_all(): + """deinit() every armed timer (used by teardown).""" + for t in tuple(_timers): + t.deinit() + + +def reset_stats(): + _stats["max_gap_ms"] = 0 + _stats["last_ms"] = None + for t in _timers: + t.late_max = 0 + t.missed = 0 + + +# -- wake source selection ---------------------------------------------- + +_SOURCE_NAMES = ("wasm", "machine", "signal", "pending", "asyncio", "native", "none") + + +def _forced_source(): + try: + import os + + getenv = getattr(os, "getenv", None) + if getenv is None: + return None + v = getenv("MULTIMER_SOURCE") + return v.strip() or None if v else None + except Exception: + return None + + +def _load_source(name): + if name not in _SOURCE_NAMES: + raise ValueError("unknown multimer source %r; expected one of %s" % (name, _SOURCE_NAMES)) + mod = __import__("multimer._src_" + name, None, None, ("_src_" + name,)) + mod.start(wake_from_source) + return mod + + +def _async_owned_host(): + """True on hosts whose lifecycle is an asyncio loop we did not start.""" + try: + import pyscript # noqa: F401 + + return True + except Exception: + pass + try: + get_ipython() # noqa: F821 + return True + except Exception: + return False + + +def _select_source(): + forced = _forced_source() + if forced is not None: + return _load_source(forced) + impl = sys.implementation.name + tried = [] + order = [] + try: + import _wasm_bridge # noqa: F401 + + order.append("wasm") + except ImportError: + pass + if impl == "micropython": + order += ["machine", "native", "signal"] + elif impl == "cpython": + from ._asyncio_loader import loop_running + + if _async_owned_host() or loop_running(): + order.append("asyncio") + if sys.platform in ("linux", "darwin") and sys.platform != "android": + order.append("signal") + order.append("pending") + order.append("none") + for name in order: + try: + return _load_source(name) + except (ImportError, AttributeError, OSError, RuntimeError) as exc: + tried.append("%s (%s)" % (name, exc)) + raise ImportError("multimer: no wake source; tried " + ", ".join(tried)) + + +def _ensure_source(): + global _source, _source_error + if _source is not None: + return _source + try: + _source = _select_source() + except Exception as exc: + _source_error = exc + _source = _load_source("none") + return _source + + +def source(): + """The wake source module in use (selected on first arm), or None.""" + return _source + + +def stop_source(): + global _source + src = _source + if src is not None: + try: + src.stop() + except Exception: + pass + _source = None + + +def info(): + src = _ensure_source() if _timers else _source + d = { + "source": None if src is None else src.name, + "delivery": None if src is None else src.delivery, + "wakes_blocking": None if src is None else src.wakes_blocking, + "host": "%s/%s" % (sys.implementation.name, sys.platform), + "timers": len(_timers), + "held": _held, + "keepalive": _keepalive, + "next_ms": next_delay_ms(), + } + d.update(_stats) + if _source_error is not None: + d["source_error"] = repr(_source_error) + return d diff --git a/lib/multimer/_hostloop.py b/lib/multimer/_hostloop.py new file mode 100644 index 00000000..3db7205b --- /dev/null +++ b/lib/multimer/_hostloop.py @@ -0,0 +1,412 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Who owns the main thread after the script body ends. + +The dispatcher answers *how a callback reaches the main thread*. This module +answers the separate question *who holds the main thread once the script's +last line has run*, which is what ``app.run()`` used to exist for. + +Three strategies, chosen once by :func:`install`: + +``ambient`` + The host already runs a loop that outlives the script body: a browser + page, a Jupyter kernel, an MCU REPL after ``main.py``, or ``-i``. Nothing + to do but register teardown. + +``exit_hook`` + Script mode on CPython, MicroPython or CircuitPython. An interpreter exit + hook takes the main thread during shutdown and delivers timers until + :func:`multimer.alive` is false: ``keepalive`` was cleared or no timer is + armed. The script "drops out the bottom" and the program keeps running. + +``none`` + No mechanism, or the entry point is ``-m`` / ``-c`` (a test runner or a + one-liner, never an app). The caller blocks explicitly + (``multimer.run_until``). + +This is ``appdev._hostloop`` moved down a layer; the classification code is +unchanged and its tests still apply. +""" + +import sys + +AMBIENT = "ambient" +EXIT_HOOK = "exit_hook" +NONE = "none" + +_state = { + "strategy": None, + "on_stop": [], + "started": False, + "stopped": False, + "crashed": False, + "claimed": False, +} + + +# --------------------------------------------------------------------------- +# Host classification +# --------------------------------------------------------------------------- + + +def _impl(): + return getattr(sys.implementation, "name", "") + + +def _mcu(): + """True on microcontroller firmware, as opposed to a desktop OS build.""" + return _impl() in ("micropython", "circuitpython") and sys.platform not in ( + "linux", + "win32", + "darwin", + ) + + +def _main_file(): + """``__main__.__file__``, or None at a bare REPL.""" + m = sys.modules.get("__main__") + if m is None: + try: + import __main__ as m + except Exception: + return None + return getattr(m, "__file__", None) + + +def _cmdline_tokens(): + """Argv tokens including flags, or () when unavailable. + + ``sys.argv`` omits interpreter flags on every implementation we target, so + the real command line has to come from the OS. ``/proc/self/cmdline`` + covers Linux (and Android); ``GetCommandLineW`` covers Windows, including + ``micropython.exe`` where there is no ``/proc``. + """ + try: + with open("/proc/self/cmdline", "rb") as f: + toks = tuple(t.decode() for t in f.read().split(b"\0") if t) + if toks: + return toks + except Exception: + pass + if sys.platform == "win32": + try: + return _win32_cmdline() + except Exception: + pass + return () + + +def _win32_cmdline(): + """Command line via ``kernel32!GetCommandLineW`` (CPython and MicroPython).""" + if _impl() == "micropython": + import ffi + import uctypes + + k32 = ffi.open("kernel32.dll") + get = k32.func("p", "GetCommandLineW", "") + addr = get() + out = bytearray() + off = 0 + while True: + pair = uctypes.bytes_at(addr + off, 2) + if pair == b"\0\0": + break + out += pair + off += 2 + text = _utf16le(out) + else: + import ctypes + + ctypes.windll.kernel32.GetCommandLineW.restype = ctypes.c_wchar_p + text = ctypes.windll.kernel32.GetCommandLineW() + return tuple(_split_cmdline(text)) + + +def _utf16le(data): + """Decode UTF-16LE by hand: MicroPython has no ``utf-16-le`` codec.""" + chars = [] + for i in range(0, len(data) - 1, 2): + code = data[i] | (data[i + 1] << 8) + chars.append("?" if 0xD800 <= code <= 0xDFFF else chr(code)) + return "".join(chars) + + +def _split_cmdline(text): + """Minimal CommandLineToArgvW: enough to spot a bare ``-i`` flag.""" + out = [] + cur = [] + in_quotes = False + for ch in text: + if ch == '"': + in_quotes = not in_quotes + elif ch in " \t" and not in_quotes: + if cur: + out.append("".join(cur)) + cur = [] + else: + cur.append(ch) + if cur: + out.append("".join(cur)) + return out + + +def ambient(): + """True when the host runs a loop that outlives the script body. + + Browser/wasm and Jupyter own the program lifecycle outright. MicroPython + firmware drops to a REPL after ``main.py``, and the REPL runs scheduled + callbacks while it waits, so it is a real ambient loop. CircuitPython is + excluded: its supervisor resets the port after ``code.py`` returns. + """ + if sys.platform in ("emscripten", "webassembly"): + return True + try: + import pyscript # noqa: F401 + + return True + except Exception: + pass + try: + get_ipython() # noqa: F821 + return True + except Exception: + pass + if _mcu() and _impl() == "micropython": + return True + return False + + +def interactive(): + """True when a REPL prompt will remain after the current top-level work.""" + if _impl() == "cpython": + flags = getattr(sys, "flags", None) + if getattr(flags, "interactive", 0): + return True + return _main_file() is None + if _mcu() and _impl() == "circuitpython": + # CircuitPython cannot tell code.py from the REPL, and delivers + # nothing in the background either way: the exit hook is the only + # thing that can hold the VM. + return False + toks = _cmdline_tokens() + if toks: + if "-i" in toks: + return True + if "-c" in toks or "-m" in toks: + return False + main = _main_file() + return main is None or main in ("", "") + + +def batch(): + """True when the interpreter was started with ``-m`` or ``-c``.""" + toks = _cmdline_tokens() + return "-m" in toks or "-c" in toks + + +def on_exit(fn): + """Register ``fn()`` to run at interpreter shutdown. True when registered.""" + try: + import atexit + + atexit.register(fn) + return True + except ImportError: + pass + hook = getattr(sys, "atexit", None) + if hook is not None: + try: + hook(fn) + return True + except Exception: + return False + return False + + +# --------------------------------------------------------------------------- +# Crash guard +# --------------------------------------------------------------------------- + + +def _crashed(): + """True when the script body died with an uncaught exception.""" + if _state["crashed"]: + return True + try: + return sys.exc_info()[0] is not None + except Exception: + return False + + +def _install_crash_guard(): + prev = getattr(sys, "excepthook", None) + if prev is None: + return + + def _hook(exc_type, exc, tb): + if not issubclass(exc_type, SystemExit): + _state["crashed"] = True + prev(exc_type, exc, tb) + + try: + sys.excepthook = _hook + except Exception: + pass + + +def mark_crashed(): + """Suppress the exit-hook loop (the program is not in a runnable state).""" + _state["crashed"] = True + + +def claim(): + """The caller is running the loop itself (``run_until``); the hook only tears down.""" + _state["claimed"] = True + + +def release(): + _state["claimed"] = False + + +def on_stop(fn): + """Register teardown to run when the process stops keeping the program alive.""" + if fn not in _state["on_stop"]: + _state["on_stop"].append(fn) + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +def _stop(): + if _state["stopped"]: + return + _state["stopped"] = True + for fn in tuple(_state["on_stop"]): + try: + fn() + except BaseException: + pass + + +def _cp_break_watch(): + """Zero-arg "did the user press Ctrl-C" probe for CircuitPython, else None. + + CircuitPython does not arm Ctrl-C as an interrupt character while an + atexit handler runs: it arrives as stdin data. A loop that never reads + stdin fills the USB CDC ring and the board stops answering every tool + that could recover it. Draining the ring each pass fixes both halves. + """ + if _impl() != "circuitpython" or not _mcu(): + return None + try: + import supervisor + + rt = supervisor.runtime + except Exception: + return None + try: + backlog = rt.serial_bytes_available + if backlog: + sys.stdin.read(backlog) + except Exception: + pass + + def pressed(): + try: + waiting = rt.serial_bytes_available + if not waiting: + return False + return "\x03" in sys.stdin.read(waiting) + except Exception: + return False + + return pressed + + +def _run_loop(): + from . import _dispatch + + interrupted = _cp_break_watch() + while _dispatch.alive(): + wait = _dispatch.next_delay_ms() + _dispatch.sleep_ms(50 if wait is None else min(wait, 50)) + if interrupted is not None and interrupted(): + break + + +def _exit_hook(): + # Never let an exception escape: MicroPython turns an uncaught exception + # in sys.atexit into "FATAL: uncaught NLR", CPython prints a traceback. + try: + if not _state["claimed"] and not _crashed(): + _run_loop() + except BaseException: + pass + _stop() + try: + from . import _dispatch + + _dispatch.stop_all() + _dispatch.stop_source() + except BaseException: + pass + + +def ensure_installed(): + """Decide the strategy once; idempotent and never raises.""" + if _state["strategy"] is not None: + return _state["strategy"] + try: + return _install() + except BaseException: + _state["strategy"] = NONE + return NONE + + +def _install(): + if ambient() or interactive(): + _state["strategy"] = AMBIENT + on_exit(_stop) + _maybe_input_hook() + return AMBIENT + if batch(): + on_exit(_stop) + _state["strategy"] = NONE + return NONE + _install_crash_guard() + if on_exit(_exit_hook): + _state["strategy"] = EXIT_HOOK + return EXIT_HOOK + _state["strategy"] = NONE + return NONE + + +def _maybe_input_hook(): + if _impl() != "cpython": + return + try: + get_ipython() # noqa: F821 + return # the kernel owns the hook and the loop + except Exception: + pass + try: + from . import _inputhook + + _inputhook.install() + except Exception: + pass + + +def strategy(): + """The strategy chosen, or None before a timer was armed.""" + return _state["strategy"] + + +def _reset_for_test(): + _state["strategy"] = None + _state["on_stop"] = [] + for k in ("started", "stopped", "crashed", "claimed"): + _state[k] = False diff --git a/lib/multimer/_inputhook.py b/lib/multimer/_inputhook.py new file mode 100644 index 00000000..c2753422 --- /dev/null +++ b/lib/multimer/_inputhook.py @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Serve timers while CPython's REPL waits for a key (``PyOS_InputHook``). + +readline calls the hook about every 100 ms while idle and after each +keystroke; Python 3.13's ``_pyrepl`` calls it from its own wait loop on both +the Unix and the Windows console. Waiting in the hook's *own* loop, on stdin, +delivering as deadlines pass and returning the moment a key arrives, makes +delivery at the prompt as punctual as anywhere else instead of 100 ms +coarse. The hook is installed only when the slot is empty, so a host that +owns it (matplotlib, IPython) keeps it. +""" + +import sys + +if sys.implementation.name != "cpython": + raise ImportError("PyOS_InputHook is CPython's") + +import ctypes + +_HOOK = ctypes.CFUNCTYPE(ctypes.c_int) +_installed = None +_wait_for_key = None +_MAX_SLICE_MS = 50 + + +def _unix_wait(fd): + import select + + def wait(ms): + try: + r, _, _ = select.select([fd], [], [], ms / 1000.0) + except (OSError, ValueError): + return True + return bool(r) + + return wait + + +def _win32_wait(fd): + import msvcrt + + k32 = ctypes.windll.kernel32 + handle = msvcrt.get_osfhandle(fd) + + def wait(ms): + # 0 means signalled; a console handle signals on any input record. + return k32.WaitForSingleObject(ctypes.c_void_p(handle), int(ms)) == 0 + + return wait + + +def _hook(): + from . import _dispatch + + try: + while True: + wait = _dispatch.deliver() + if wait is None: + return 0 + if _wait_for_key(min(wait, _MAX_SLICE_MS)): + return 0 + except Exception: + return 0 + + +def install(): + """Install the hook if stdin is usable and the slot is free. True if installed.""" + global _installed, _wait_for_key + if _installed is not None: + return True + try: + import os + + if os.environ.get("MULTIMER_INPUTHOOK", "1") == "0": + return False # tests prove the hook matters by turning it off + except Exception: + pass + try: + fd = sys.stdin.fileno() + except (AttributeError, ValueError, OSError): + return False + slot = ctypes.c_void_p.in_dll(ctypes.pythonapi, "PyOS_InputHook") + if slot.value: + return False + _wait_for_key = _win32_wait(fd) if sys.platform == "win32" else _unix_wait(fd) + cfunc = _HOOK(_hook) + slot.value = ctypes.cast(cfunc, ctypes.c_void_p).value + _installed = cfunc # keep the trampoline alive for the life of the process + return True + + +def installed(): + return _installed is not None diff --git a/lib/multimer/_repl.py b/lib/multimer/_repl.py new file mode 100644 index 00000000..2892a514 --- /dev/null +++ b/lib/multimer/_repl.py @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""A line REPL that keeps delivering timers while it reads. + +For hosts with no prompt to fall through to: CircuitPython, whose supervisor +resets the board when ``code.py`` returns, and any program that must hold the +main thread itself. ``multimer.repl()`` reads lines from stdin between +deliveries and evaluates them in the caller's namespace, so ``report()`` and +the program's own objects are reachable over the same serial port without +stopping it. Ctrl-D (or ``quit()``) returns. +""" + +import sys + +from . import _dispatch + + +def _reader(): + """A zero-arg callable returning available stdin text, or ''.""" + try: + import supervisor + + rt = supervisor.runtime + + def read(): + n = rt.serial_bytes_available + return sys.stdin.read(n) if n else "" + + # Only boards have the attribute; the unix build raises here. + rt.serial_bytes_available + return read + except Exception: + pass + try: + import select + except ImportError: + return None + # poll() exists on CPython, MicroPython and CircuitPython; select() only + # on the first two. + poll = getattr(select, "poll", None) + if poll is not None: + try: + poller = poll() + poller.register(sys.stdin, select.POLLIN) + + def read(): + if not poller.poll(0): + return "" + data = sys.stdin.readline() + return data if data else "\x04" + + return read + except Exception: + pass + sel = getattr(select, "select", None) + if sel is not None: + try: + fd = sys.stdin.fileno() + + def read(): + r, _, _ = sel([fd], [], [], 0) + if not r: + return "" + data = sys.stdin.readline() + return data if data else "\x04" + + return read + except Exception: + pass + return None + + +def repl(namespace=None, prompt=">>> ", tick_ms=10): + """Read-eval-print until EOF, delivering timers between lines. + + Returns when stdin closes or the user enters ``quit()``. Statements + ending with ``:`` are read in blocks up to a blank line. + """ + if namespace is None: + # CircuitPython keeps __main__ out of sys.modules; importing it works. + main = sys.modules.get("__main__") + if main is None: + try: + main = __import__("__main__") + except ImportError: + main = None + namespace = main.__dict__ if main is not None else {} + read = _reader() + if read is None: + raise RuntimeError("multimer.repl: no way to read stdin without blocking on this host") + sys.stdout.write("multimer.repl: timers keep running; Ctrl-D or quit() returns\n") + sys.stdout.write(prompt) + buf = "" + block = [] + while True: + _dispatch.sleep_ms(tick_ms) + data = read() + if not data: + continue + if "\x04" in data: + sys.stdout.write("\n") + return + buf += data + while "\n" in buf: + line, buf = buf.split("\n", 1) + line = line.rstrip("\r") + if block: + if line.strip(): + block.append(line) + sys.stdout.write("... ") + continue + src = "\n".join(block) + block = [] + elif line.rstrip().endswith(":"): + block.append(line) + sys.stdout.write("... ") + continue + else: + src = line + if src.strip() in ("quit()", "exit()"): + return + _run(src, namespace) + sys.stdout.write(prompt) + + +def _run(src, namespace): + try: + try: + code = compile(src, "", "eval") + except SyntaxError: + code = compile(src, "", "exec") + exec(code, namespace) + else: + value = eval(code, namespace) + if value is not None: + sys.stdout.write(repr(value) + "\n") + except Exception as exc: + _dispatch._print_exception(exc) diff --git a/lib/multimer/_schedule.py b/lib/multimer/_schedule.py deleted file mode 100644 index 9feca267..00000000 --- a/lib/multimer/_schedule.py +++ /dev/null @@ -1,115 +0,0 @@ -# SPDX-FileCopyrightText: 2024 Brad Barnett -# -# SPDX-License-Identifier: MIT -"""Cross-platform schedule compatible with micropython.schedule.""" - -import sys - -if sys.implementation.name in ("cpython", "circuitpython"): - _pending = [] - _pending_lock = None - - try: - import threading - - _main_ident = threading.main_thread().ident - - def _is_main_thread(): - return threading.current_thread().ident == _main_ident - - except ImportError: - try: - import _thread - - _main_ident = _thread.get_ident() - - def _is_main_thread(): - return _thread.get_ident() == _main_ident - - except ImportError: - - def _is_main_thread(): - return True - - try: - import _thread - - _pending_lock = _thread.allocate_lock() - except ImportError: - pass - - def _queue(cb, arg): - if _pending_lock is not None: - _pending_lock.acquire() - try: - _pending.append((cb, arg)) - finally: - _pending_lock.release() - else: - _pending.append((cb, arg)) - - def _pop_pending(): - if _pending_lock is not None: - _pending_lock.acquire() - try: - if not _pending: - return None - return _pending.pop(0) - finally: - _pending_lock.release() - if not _pending: - return None - return _pending.pop(0) - - _draining = False - - def _run_pending(): - # Reentrancy guard: on the librt backend the periodic timer is delivered - # by an RT signal handler that runs on the main thread. If that fires - # while we already hold ``_pending_lock`` here, the handler's own - # schedule()/_run_pending() would re-acquire the non-reentrant lock and - # self-deadlock. Skip the reentrant drain — the outer loop keeps - # draining, and schedule() still invokes the delivered callback directly. - global _draining - if not _is_main_thread() or _draining: - return - _draining = True - try: - while True: - item = _pop_pending() - if item is None: - return - cb, arg = item - cb(arg) - finally: - _draining = False - - def schedule(cb, arg): - """Queue ``cb(arg)`` for the main thread (``micropython.schedule`` compatible). - - On CPython / CircuitPython: if called off the main thread, append to a - pending queue drained by the next main-thread :func:`schedule` or by - the timer sleep pump. On the main thread (including librt RT-signal - delivery), drain pending work then invoke ``cb(arg)`` immediately — - soft timers do not get an extra deferral hop when already on main. - - On MicroPython, this is the built-in ``micropython.schedule``. - - Args: - cb: Zero-or-one-arg callable invoked as ``cb(arg)``. - arg: Argument passed to ``cb`` (often the timer instance). - - Raises: - RuntimeError: On MicroPython when the schedule queue is full. - """ - if not _is_main_thread(): - _queue(cb, arg) - return - _run_pending() - cb(arg) - -else: - from micropython import schedule - - def _run_pending(): - pass diff --git a/lib/multimer/_src_asyncio.py b/lib/multimer/_src_asyncio.py new file mode 100644 index 00000000..8c4013d3 --- /dev/null +++ b/lib/multimer/_src_asyncio.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The running asyncio loop as the wake source (Jupyter, PyScript, async apps). + +``loop.call_later`` to the earliest deadline; the callback runs the +dispatcher at an await point of the loop that owns the process. Cells and +pages return to that loop, so timers keep firing after a cell or the script +body ends. Only chosen when a loop is already running, or the host is one +whose lifecycle is a loop (``pyscript``, ``get_ipython``). +""" + +import sys + +if sys.implementation.name != "cpython": + raise ImportError("asyncio source is CPython only") + +import asyncio + +name = "asyncio" +delivery = "idle" +wakes_blocking = False + +_wake = None +_loop = None +_handle = None + + +def _fire(): + global _handle + _handle = None + w = _wake + if w is not None: + w() + + +def start(wake): + global _wake, _loop + _wake = wake + try: + _loop = asyncio.get_running_loop() + except RuntimeError: + # Jupyter with no cell coroutine running, PyScript at import: + # the loop exists and will run; a not-yet-running loop is fine too. + _loop = asyncio.get_event_loop() + + +def arm(delay_ms): + global _handle + loop = _loop + if loop is None: + return + if _handle is not None: + _handle.cancel() + _handle = None + try: + _handle = loop.call_later(max(0, int(delay_ms)) / 1000.0, _fire) + except RuntimeError: + # Called from another thread: hand it to the loop's thread. + loop.call_soon_threadsafe(arm, delay_ms) + + +def cancel(): + global _handle + if _handle is not None: + _handle.cancel() + _handle = None + + +def stop(): + global _wake + cancel() + _wake = None diff --git a/lib/multimer/_src_machine.py b/lib/multimer/_src_machine.py new file mode 100644 index 00000000..7d8762d7 --- /dev/null +++ b/lib/multimer/_src_machine.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""One ``machine.Timer`` as the wake source (MicroPython on a board). + +A single hardware or virtual timer, ONE_SHOT, re-armed to the earliest +deadline from the dispatcher. Its callback is already soft on esp32, rp2 and +stm32 (the port hands it to ``micropython.schedule``); ``wake_from_source`` +queues the dispatcher the same way, so the user's callbacks run between +bytecodes of the main thread, and at the REPL while it waits for a key. + +Boards have few timers (four on an ESP32); this uses one for every +``multimer.Timer`` in the program. +""" + +from machine import Timer as _HW + +name = "machine" +delivery = "bytecode" +wakes_blocking = True + +_wake = None +_hw = None +_armed_ms = None + + +def _cb(_t): + # A soft machine.Timer callback: the port already delivered it through + # micropython.schedule, so this is a bytecode boundary of the main thread. + w = _wake + if w is not None: + w(True) + + +def start(wake): + global _wake, _hw + _wake = wake + if _hw is None: + last = None + # -1 asks for a virtual timer where the port has them; ports that + # number hardware timers take the first free id. + for tid in (-1, 0, 1, 2, 3): + try: + _hw = _HW(tid) + break + except (ValueError, OSError) as exc: + last = exc + if _hw is None: + raise last + + +def arm(delay_ms): + global _armed_ms + ms = int(delay_ms) + if ms < 1: + ms = 1 + _armed_ms = ms + _hw.init(mode=_HW.ONE_SHOT, period=ms, callback=_cb) + + +def cancel(): + global _armed_ms + _armed_ms = None + try: + _hw.deinit() + except Exception: + pass + + +def stop(): + global _wake + cancel() + _wake = None diff --git a/lib/multimer/_src_native.py b/lib/multimer/_src_native.py new file mode 100644 index 00000000..6a9cde4f --- /dev/null +++ b/lib/multimer/_src_native.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""A C helper as the wake source (MicroPython builds with the ``_timing`` module). + +For ports with neither signals nor ``machine.Timer`` -- the windows port. +``_timing`` (micropython-pydevices, patch 0015) owns one OS timer whose +expiry calls ``mp_sched_schedule`` on the Python callable given to +``_timing.init``; the callback runs at the next bytecode boundary, and the +port's console wait services pending callbacks while the REPL is idle. +""" + +import _timing + +name = "native" +delivery = "bytecode" +wakes_blocking = True + +_wake = None + + +def _cb(_arg): + # Already at a bytecode boundary: the port scheduled this call. + w = _wake + if w is not None: + w(True) + + +def start(wake): + global _wake + _wake = wake + _timing.init(_cb) + + +def arm(delay_ms): + _timing.arm(max(0, int(delay_ms))) + + +def cancel(): + _timing.cancel() + + +def stop(): + global _wake + cancel() + _wake = None diff --git a/lib/multimer/_src_none.py b/lib/multimer/_src_none.py new file mode 100644 index 00000000..f3a9b875 --- /dev/null +++ b/lib/multimer/_src_none.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""No wake source: delivery only at idle points (sleep_ms, pump, the hook loops). + +CircuitPython, and any build with neither a hardware timer, signals, threads +nor a browser loop. ``multimer.repl()`` is the way in from outside. +""" + +name = "none" +delivery = "idle" +wakes_blocking = False + + +def start(wake): + pass + + +def arm(delay_ms): + pass + + +def cancel(): + pass + + +def stop(): + pass diff --git a/lib/multimer/_src_pending.py b/lib/multimer/_src_pending.py new file mode 100644 index 00000000..178052fe --- /dev/null +++ b/lib/multimer/_src_pending.py @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""A worker thread and ``Py_AddPendingCall`` as the wake source (CPython). + +The thread only keeps time. When a deadline passes it asks the interpreter +to run one C callback on the main thread at its next bytecode boundary; that +callback runs the dispatcher. So callbacks land on the main thread on every +CPython, Windows and Android included, with no signal, no APC, no alertable +wait and no SDL timer thread. + +The interpreter's pending-call queue holds 32 entries and refuses more, so +this source keeps at most one outstanding and retries a refusal a +millisecond later. A pending call does not wake a blocked ``time.sleep``; +``multimer.sleep_ms`` sleeps only until the next deadline, and the REPL's +input hook covers the prompt. +""" + +import sys + +if sys.implementation.name != "cpython": + raise ImportError("pending source is CPython only") + +import ctypes +import threading +import time + +name = "pending" +delivery = "bytecode" +wakes_blocking = False + +_wake = None +_cond = threading.Condition() +_deadline = None # perf_counter seconds, or None when idle +_stop = False +_thread = None +_pending = False + +_CB = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p) + +# Windows wakes a waiting thread on its system timer, 15.6 ms apart unless a +# process asks for finer. Measured on Windows 11 with a busy main thread: a +# 10 ms timer delivered 327 of 500 with 23 ms p99 lateness at the default, +# 501 of 500 with 8 ms (the GIL switch interval) at 1 ms. SDL and pygame ask +# for the same. +_WIN_PERIOD_MS = 1 + + +def _timer_resolution(begin): + if sys.platform != "win32": + return + try: + winmm = ctypes.windll.winmm + (winmm.timeBeginPeriod if begin else winmm.timeEndPeriod)(_WIN_PERIOD_MS) + except Exception: + pass + + +def _on_main(_arg): + global _pending + _pending = False + w = _wake + if w is not None: + w() + return 0 + + +_cfunc = _CB(_on_main) # referenced for the life of the module + + +def _worker(): + global _pending + add = ctypes.pythonapi.Py_AddPendingCall + add.restype = ctypes.c_int + add.argtypes = [_CB, ctypes.c_void_p] + with _cond: + while not _stop: + if _deadline is None: + _cond.wait() + continue + now = time.perf_counter() + if now < _deadline: + _cond.wait(_deadline - now) + continue + if _pending: + # Already asked; the main thread has not got there yet. + _cond.wait(0.001) + continue + _pending = True + rc = add(_cfunc, None) + if rc != 0: + _pending = False + _cond.wait(0.001) + continue + # Delivered once the main thread reaches a bytecode boundary. The + # dispatcher re-arms from there; until it does, stay idle. + _set_deadline_locked(None) + + +def _set_deadline_locked(value): + global _deadline + _deadline = value + _cond.notify() + + +def start(wake): + global _wake, _thread, _stop + _wake = wake + _stop = False + _timer_resolution(True) + if _thread is None or not _thread.is_alive(): + _thread = threading.Thread(target=_worker, name="multimer-pending", daemon=True) + _thread.start() + + +def arm(delay_ms): + with _cond: + _set_deadline_locked(time.perf_counter() + max(0, int(delay_ms)) / 1000.0) + + +def cancel(): + with _cond: + _set_deadline_locked(None) + + +def stop(): + global _stop, _wake + with _cond: + _stop = True + _set_deadline_locked(None) + _wake = None + _timer_resolution(False) diff --git a/lib/multimer/_src_signal.py b/lib/multimer/_src_signal.py new file mode 100644 index 00000000..ff616da7 --- /dev/null +++ b/lib/multimer/_src_signal.py @@ -0,0 +1,260 @@ +# SPDX-FileCopyrightText: 2021 Amir Gonnen +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""POSIX timer signal as the wake source (CPython and unix MicroPython). + +One kernel timer for the whole process, armed one-shot to the earliest +deadline. Its signal reaches the main thread: + +* on CPython the Python-level handler runs between two bytecodes of the main + thread, and a blocking call that the signal interrupts is retried after the + handler (PEP 475): that is bytecode delivery with sleeps and reads woken; +* on MicroPython the ffi handler runs in signal context with the heap locked, + so it does exactly one thing: ``micropython.schedule`` the dispatcher. The + callback then runs at the next bytecode boundary, and a ``read()`` at the + REPL is interrupted (EINTR), runs pending callbacks, and is retried + (``ports/unix/mphalport.h``, ``MP_HAL_RETRY_SYSCALL``). + +Linux gets a real-time signal from ``timer_create`` so nothing shares it; +other POSIX hosts fall back to ``setitimer`` on SIGALRM. +""" + +import sys + +if sys.platform not in ("linux", "darwin"): + raise ImportError("signal source needs a POSIX host") + +name = "signal" +delivery = "bytecode" +wakes_blocking = True + +_CLOCK_MONOTONIC = 1 +_SIGEV_THREAD_ID = 4 +_wake = None +_armed_ms = None + +_USE_CTYPES = sys.implementation.name == "cpython" + +if _USE_CTYPES: + import ctypes + import signal + + _libc = ctypes.CDLL(None, use_errno=True) + _have_timer_create = hasattr(_libc, "timer_create") and sys.platform == "linux" + + class _timespec(ctypes.Structure): + _fields_ = [("tv_sec", ctypes.c_long), ("tv_nsec", ctypes.c_long)] + + class _itimerspec(ctypes.Structure): + _fields_ = [("it_interval", _timespec), ("it_value", _timespec)] + + class _sigval(ctypes.Union): + _fields_ = [("sival_int", ctypes.c_int), ("sival_ptr", ctypes.c_void_p)] + + class _sigevent(ctypes.Structure): + _fields_ = [ + ("sigev_value", _sigval), + ("sigev_signo", ctypes.c_int), + ("sigev_notify", ctypes.c_int), + ("sigev_notify_thread_id", ctypes.c_int), + ("_pad", ctypes.c_char * 40), + ] + + _timer = None + _signo = None + + def _handler(_signum, _frame=None): + w = _wake + if w is not None: + w() + + def start(wake): + global _wake, _timer, _signo + _wake = wake + if _have_timer_create: + _libc.timer_create.restype = ctypes.c_int + _libc.timer_create.argtypes = [ctypes.c_int, ctypes.POINTER(_sigevent), ctypes.POINTER(ctypes.c_void_p)] + _libc.timer_settime.restype = ctypes.c_int + _libc.timer_settime.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(_itimerspec), ctypes.POINTER(_itimerspec)] + _libc.timer_delete.restype = ctypes.c_int + _libc.timer_delete.argtypes = [ctypes.c_void_p] + _libc.gettid.restype = ctypes.c_int + _signo = signal.SIGRTMIN + 4 + sev = _sigevent() + sev.sigev_notify = _SIGEV_THREAD_ID + sev.sigev_signo = _signo + sev.sigev_notify_thread_id = _libc.gettid() + tid = ctypes.c_void_p() + signal.signal(_signo, _handler) + if _libc.timer_create(_CLOCK_MONOTONIC, ctypes.byref(sev), ctypes.byref(tid)) != 0: + signal.signal(_signo, signal.SIG_IGN) + raise OSError("timer_create failed (errno=%d)" % ctypes.get_errno()) + _timer = tid + else: + _signo = signal.SIGALRM + signal.signal(_signo, _handler) + # A signal must interrupt a blocking call so the handler runs now; + # PEP 475 then retries the call for the caller. + signal.siginterrupt(_signo, True) + + def _settime(ms): + if _have_timer_create: + spec = _itimerspec() + spec.it_value.tv_sec = ms // 1000 + spec.it_value.tv_nsec = (ms % 1000) * 1_000_000 + if ms == 0: + spec.it_value.tv_nsec = 0 + _libc.timer_settime(_timer, 0, ctypes.byref(spec), None) + else: + signal.setitimer(signal.ITIMER_REAL, ms / 1000.0, 0) + + def arm(delay_ms): + global _armed_ms + # 0 disarms a POSIX timer; the smallest positive value fires at once. + ms = int(delay_ms) + if ms <= 0: + if _have_timer_create: + spec = _itimerspec() + spec.it_value.tv_nsec = 1 + _libc.timer_settime(_timer, 0, ctypes.byref(spec), None) + else: + signal.setitimer(signal.ITIMER_REAL, 1e-6, 0) + _armed_ms = 0 + return + _armed_ms = ms + _settime(ms) + + def cancel(): + global _armed_ms + _armed_ms = None + _settime(0) + + def stop(): + global _timer, _wake + cancel() + if _have_timer_create and _timer is not None: + _libc.timer_delete(_timer) + _timer = None + if _signo is not None: + try: + signal.signal(_signo, signal.SIG_IGN) + except Exception: + pass + _wake = None + +else: + import array + + import ffi + import uctypes + + _libc = ffi.open("libc.so.6") + try: + _librt = ffi.open("librt.so.1") + except OSError: + _librt = _libc + + _timer_create_ = _librt.func("i", "timer_create", "ipp") + _timer_delete_ = _librt.func("i", "timer_delete", "P") + _timer_settime_ = _librt.func("i", "timer_settime", "PiPp") + _sigaction_ = _libc.func("i", "sigaction", "iPp") + _sigrtmin = _libc.func("i", "__libc_current_sigrtmin", "")() + try: + _gettid = _libc.func("i", "gettid", "") + except OSError: + _syscall = _libc.func("l", "syscall", "l") + + def _gettid(): + return _syscall(186) + + _sigaction_t = { + "sa_handler": 0 | uctypes.UINT64, + "sa_mask": (8 | uctypes.ARRAY, 16 | uctypes.UINT64), + "sa_flags": 136 | uctypes.INT32, + "sa_restorer": (144 | uctypes.PTR, uctypes.UINT8), + } + _sigevent_t = { + "sigev_value": 0 | uctypes.UINT64, + "sigev_signo": 8 | uctypes.INT32, + "sigev_notify": 12 | uctypes.INT32, + "sigev_notify_thread_id": 16 | uctypes.INT32, + } + _timespec_t = {"tv_sec": 0 | uctypes.INT64, "tv_nsec": 8 | uctypes.INT64} + _itimerspec_t = {"it_interval": (0, _timespec_t), "it_value": (16, _timespec_t)} + + def _struct(desc): + buf = bytearray(uctypes.sizeof(desc)) + return uctypes.struct(uctypes.addressof(buf), desc, uctypes.NATIVE), buf + + _timer = None + _signo = None + _cb = None + _keep = [] + # Pre-built itimerspec so arm() allocates nothing it does not have to. + _spec, _spec_buf = _struct(_itimerspec_t) + _old, _old_buf = _struct(_itimerspec_t) + + def _handler(_signum): + # Signal context, heap locked: schedule and return. + w = _wake + if w is not None: + w() + + def start(wake): + global _wake, _timer, _signo, _cb + _wake = wake + _signo = _sigrtmin + 4 + _cb = ffi.callback("v", _handler, "i", lock=True) + sa, sa_buf = _struct(_sigaction_t) + sa_old, sa_old_buf = _struct(_sigaction_t) + sa.sa_handler = _cb.cfun() + _keep.extend((sa_buf, sa_old_buf)) + if _sigaction_(_signo, sa, sa_old) != 0: + raise OSError("sigaction failed") + sev, sev_buf = _struct(_sigevent_t) + _keep.append(sev_buf) + sev.sigev_notify = _SIGEV_THREAD_ID + sev.sigev_signo = _signo + sev.sigev_notify_thread_id = _gettid() + tid = array.array("P", [0]) + if _timer_create_(_CLOCK_MONOTONIC, sev, tid) != 0: + raise OSError("timer_create failed") + _timer = tid[0] + + def arm(delay_ms): + global _armed_ms + ms = int(delay_ms) + _spec.it_interval.tv_sec = 0 + _spec.it_interval.tv_nsec = 0 + if ms <= 0: + _spec.it_value.tv_sec = 0 + _spec.it_value.tv_nsec = 1 + _armed_ms = 0 + else: + _spec.it_value.tv_sec = ms // 1000 + _spec.it_value.tv_nsec = (ms % 1000) * 1000000 + _armed_ms = ms + _timer_settime_(_timer, 0, _spec, _old) + + def cancel(): + global _armed_ms + _armed_ms = None + _spec.it_value.tv_sec = 0 + _spec.it_value.tv_nsec = 0 + _spec.it_interval.tv_sec = 0 + _spec.it_interval.tv_nsec = 0 + _timer_settime_(_timer, 0, _spec, _old) + + def stop(): + global _timer, _wake + if _timer is not None: + cancel() + _timer_delete_(_timer) + _timer = None + if _signo is not None: + sa, sa_buf = _struct(_sigaction_t) + sa_old, sa_old_buf = _struct(_sigaction_t) + sa.sa_handler = 1 # SIG_IGN: a late signal must not kill the process + _sigaction_(_signo, sa, sa_old) + _wake = None diff --git a/lib/multimer/_src_wasm.py b/lib/multimer/_src_wasm.py new file mode 100644 index 00000000..3e8bd16b --- /dev/null +++ b/lib/multimer/_src_wasm.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The browser's timer as the wake source (direct MicroPython WebAssembly). + +``_wasm_bridge.timer_start`` is ``setTimeout`` with a Python callback the +bridge calls once the VM has returned to the page's event loop: an idle +point by construction, and the page loop outlives the script, so no pump is +needed after the script body ends. While Python is inside an Asyncify sleep +the bridge cannot call in; it queues the firing, and ``sleep_ms``'s poll +picks it up. +""" + +import _wasm_bridge + +name = "wasm" +delivery = "idle" +wakes_blocking = False + +_ID = 0x7E11 # one browser timer for the dispatcher +_wake = None +_armed = False + + +def _fire(): + global _armed + _armed = False + w = _wake + if w is not None: + w() + + +def start(wake): + global _wake + _wake = wake + + +def arm(delay_ms): + global _armed + _armed = True + _wasm_bridge.timer_start(_ID, max(0, int(delay_ms)), False, _fire) + + +def cancel(): + global _armed + _armed = False + # Unconditional: the bridge ignores an id it no longer holds, and a + # one-shot that already fired was deleted on the browser side. + _wasm_bridge.timer_cancel(_ID) + + +def stop(): + global _wake + cancel() + _wake = None + + +def poll(): + """Deliver firings the bridge queued while Python was sleeping.""" + fired = False + while True: + tid = _wasm_bridge.timer_poll() + if tid is None: + break + if tid == _ID: + fired = True + if fired: + _fire() + + +def sleep_ms(ms): + _wasm_bridge.sleep_ms(max(0, int(ms))) + poll() diff --git a/lib/multimer/_ticks.py b/lib/multimer/_ticks.py index 955fbff4..2ebe67db 100644 --- a/lib/multimer/_ticks.py +++ b/lib/multimer/_ticks.py @@ -86,6 +86,29 @@ def _monotonic_from_ticks_ms(): _impl_monotonic = _monotonic_from_ticks_ms +_impl_ticks_us = None +try: + from time import ticks_us as _time_ticks_us + + _impl_ticks_us = _time_ticks_us +except ImportError: + try: + from time import monotonic_ns as _mono_ns_for_us + + _mono_ns_for_us() + + def _us_from_ns(): + return (_mono_ns_for_us() // 1000) & _TICKS_MAX + + _impl_ticks_us = _us_from_ns + except (ImportError, NameError, NotImplementedError): + + def _us_from_ticks_ms(): + return (_impl_ticks_ms() * 1000) & _TICKS_MAX + + _impl_ticks_us = _us_from_ticks_ms + + def ticks_ms(): """Return a wrapping millisecond tick counter (period ``2**29`` ms). @@ -98,6 +121,16 @@ def ticks_ms(): return _impl_ticks_ms() +def ticks_us(): + """A wrapping microsecond counter (period ``2**29`` us on hosts without one of their own). + + ``time.ticks_us`` where the interpreter has it; otherwise derived from the + nanosecond monotonic clock. Pair with :func:`ticks_diff` only for + intervals under the half period (about 4.5 minutes). + """ + return _impl_ticks_us() + + def monotonic(): """Return a monotonic clock in seconds (float). @@ -148,15 +181,19 @@ def ticks_less(ticks1, ticks2): return ticks_diff(ticks1, ticks2) < 0 -def _raw_sleep_ms(ms): - try: - from time import sleep_ms as _time_sleep_ms +try: + from time import sleep_ms as _host_sleep_ms +except ImportError: + from time import sleep as _host_sleep - _time_sleep_ms(ms) - except ImportError: - import time + def _host_sleep_ms(ms): + _host_sleep(ms / 1000) - time.sleep(ms / 1000) + +def _raw_sleep_ms(ms): + """The host's own sleep, bound once so a callback delivered mid-sleep + is not chained to an ImportError we were handling.""" + _host_sleep_ms(ms) # Optional development/troubleshooting hook only — not part of normal app use. diff --git a/lib/multimer/auto.py b/lib/multimer/auto.py deleted file mode 100644 index 3026e56c..00000000 --- a/lib/multimer/auto.py +++ /dev/null @@ -1,143 +0,0 @@ -# SPDX-FileCopyrightText: 2024 Brad Barnett -# -# SPDX-License-Identifier: MIT -"""Optional host timer-provider selection. - -Importing :mod:`multimer` alone performs no backend probing. Applications that -want the former platform-selection behavior opt in explicitly:: - - from multimer import auto as timer - -Set ``MULTIMER_BACKEND`` before importing this module to force one provider. -Explicit provider imports never fall back. -""" - -import sys - -from . import AsyncTimer, _async_only_interpreter, _async_sleep_ms, _provider_pump - -_AUTO_BACKENDS = ("wasm", "machine", "librt", "win32", "sdl2", "threading", "polling") -_BACKENDS = _AUTO_BACKENDS + ("async",) -_ENV_OVERRIDE = "MULTIMER_BACKEND" - - -class _AsyncProvider: - Timer = AsyncTimer - name = "async" - uses_interrupts = False - is_async = True - _defer_sync_arm = False - sleep_ms = staticmethod(_async_sleep_ms) - - @staticmethod - def pump(): - _provider_pump() - - -def _load_backend(backend_name): - """Import one provider without fallback.""" - if backend_name == "wasm": - import _wasm_bridge # noqa: F401 - - from . import wasm as provider - elif backend_name == "machine": - from . import machine as provider - elif backend_name == "librt": - from . import librt as provider - elif backend_name == "win32": - from . import win32 as provider - elif backend_name == "sdl2": - from . import sdl2 as provider - elif backend_name == "threading": - from . import threading as provider - elif backend_name == "polling": - from . import polling as provider - elif backend_name == "async": - provider = _AsyncProvider - else: - raise ValueError( - f"unknown multimer backend {backend_name!r}; expected one of {_BACKENDS}" - ) - return provider - - -def _forced_backend(): - import os - - getenv = getattr(os, "getenv", None) - if getenv is None: - return None - try: - value = getenv(_ENV_OVERRIDE) - except Exception: - return None - if value is None: - return None - value = str(value).strip() - return value or None - - -def _pygame_available(): - try: - import pygame # noqa: F401 - - return True - except ImportError: - return False - - -def _auto_backends(): - """Return the unchanged host-specific provider order.""" - impl = getattr(sys.implementation, "name", "") - skip_sdl2 = (impl == "cpython" and _pygame_available()) or sys.platform == "android" - out = [] - for backend_name in _AUTO_BACKENDS: - if backend_name == "win32" and sys.platform != "win32": - continue - if backend_name == "sdl2" and skip_sdl2: - continue - out.append(backend_name) - return out - - -def _select_backend(): - forced = _forced_backend() - if forced is not None: - return _load_backend(forced) - try: - return _load_backend("wasm") - except ImportError: - pass - if _async_only_interpreter(): - return _AsyncProvider - - tried = [name for name in _auto_backends() if name != "wasm"] - for backend_name in tried: - try: - return _load_backend(backend_name) - except (ImportError, AttributeError): - # A provider whose native type is missing is unavailable, whatever - # the host raises reaching for it. ``from machine import Timer`` - # raises ImportError on a real module, but AttributeError when - # ``machine`` has been replaced in sys.modules by a shim -- which - # is exactly what happens on unix MicroPython, whose native - # ``machine`` has no Pin and rejects setattr, so callers install a - # forwarding proxy. Catching only ImportError let that proxy abort - # the whole search instead of falling through to librt. - pass - raise ImportError( - f"multimer.auto: no timer backend available (tried {', '.join(tried)})" - ) - - -_provider = _select_backend() - -Timer = _provider.Timer -name = _provider.name -uses_interrupts = _provider.uses_interrupts -is_async = _provider.is_async -sleep_ms = _provider.sleep_ms -pump = _provider.pump -_defer_sync_arm = _provider._defer_sync_arm - -__all__ = ["Timer", "is_async", "name", "pump", "sleep_ms", "uses_interrupts"] diff --git a/lib/multimer/librt.py b/lib/multimer/librt.py deleted file mode 100644 index 0539c404..00000000 --- a/lib/multimer/librt.py +++ /dev/null @@ -1,297 +0,0 @@ -# SPDX-FileCopyrightText: 2021 Amir Gonnen -# SPDX-FileCopyrightText: 2024 Brad Barnett -# -# SPDX-License-Identifier: MIT -""" -Linux librt Timer (``timer_create`` / ``timer_settime``). - -Uses ctypes on CPython and ffi/uctypes on MicroPython unix. Timer signals are -delivered to the thread that created the timer (the main thread), so callbacks -run without application-side servicing. -""" - -import sys - -if sys.platform != "linux": - raise ImportError("librt timer backend requires Linux") - -from . import _provider_pump, _provider_sleep_ms -from ._core import _TimerCore - -# librt fires timer callbacks via an RT signal on the main thread, so they run -# during a plain sleep without any application-side pumping. -name = "librt" -uses_interrupts = True -is_async = False -_defer_sync_arm = False - - -def pump(): - _provider_pump() - - -def sleep_ms(ms): - _provider_sleep_ms(ms, uses_interrupts=True) - - -__all__ = ["Timer", "is_async", "name", "pump", "sleep_ms", "uses_interrupts"] - -_USE_CTYPES = sys.implementation.name == "cpython" -_CLOCK_MONOTONIC = 1 -_SIGEV_THREAD_ID = 4 -_SYS_gettid = 186 -_DEFAULT_TIMER_IDS = list(range(0xF, -1, -1)) -_ALLOCATED_DEFAULT_IDS = set() - - -def _alloc_default_id(): - for timer_id in _DEFAULT_TIMER_IDS: - if timer_id not in _ALLOCATED_DEFAULT_IDS: - _ALLOCATED_DEFAULT_IDS.add(timer_id) - return timer_id - raise RuntimeError("no librt timer ids available") - - -def _free_default_id(timer_id): - _ALLOCATED_DEFAULT_IDS.discard(timer_id) - - -def _period_parts(period_ms): - total_ns = period_ms * 1_000_000 - return total_ns // 1_000_000_000, total_ns % 1_000_000_000 - - -def _apply_period(spec, period_sec, period_ns, periodic): - spec.it_value.tv_sec = period_sec - spec.it_value.tv_nsec = period_ns - if periodic: - spec.it_interval.tv_sec = period_sec - spec.it_interval.tv_nsec = period_ns - - -def _apply_sigevent(sev, signo): - sev.sigev_notify = _SIGEV_THREAD_ID - sev.sigev_signo = signo - sev.sigev_notify_thread_id = _gettid() - - -if _USE_CTYPES: - import ctypes - import signal - - libc = ctypes.CDLL("libc.so.6", use_errno=True) - try: - librt = ctypes.CDLL("librt.so.1", use_errno=True) - except OSError: - librt = libc - - class _timespec(ctypes.Structure): - _fields_ = [("tv_sec", ctypes.c_long), ("tv_nsec", ctypes.c_long)] - - class _itimerspec(ctypes.Structure): - _fields_ = [("it_interval", _timespec), ("it_value", _timespec)] - - class _sigval(ctypes.Union): - _fields_ = [("sival_int", ctypes.c_int), ("sival_ptr", ctypes.c_void_p)] - - class _sigevent(ctypes.Structure): - _fields_ = [ - ("sigev_value", _sigval), - ("sigev_signo", ctypes.c_int), - ("sigev_notify", ctypes.c_int), - ("sigev_notify_thread_id", ctypes.c_int), - ] - - def _librt_error(name, ret): - if ret != 0: - raise RuntimeError(f"{name} failed (errno={ctypes.get_errno()})") - - try: - _gettid = libc.gettid - _gettid.restype = ctypes.c_int - _gettid.argtypes = [] - except AttributeError: - libc.syscall.restype = ctypes.c_long - libc.syscall.argtypes = [ctypes.c_long] - - def _gettid(): - return libc.syscall(_SYS_gettid) - - librt.timer_create.restype = ctypes.c_int - librt.timer_create.argtypes = [ - ctypes.c_int, - ctypes.POINTER(_sigevent), - ctypes.POINTER(ctypes.c_void_p), - ] - librt.timer_delete.restype = ctypes.c_int - librt.timer_delete.argtypes = [ctypes.c_void_p] - librt.timer_settime.restype = ctypes.c_int - librt.timer_settime.argtypes = [ - ctypes.c_void_p, - ctypes.c_int, - ctypes.POINTER(_itimerspec), - ctypes.POINTER(_itimerspec), - ] - - _SIGRTMIN = signal.SIGRTMIN - -else: - import array - import os - - import ffi - import uctypes - - libc = ffi.open("libc.so.6") - try: - librt = ffi.open("librt.so") - except OSError: - librt = libc - - _timer_create_ = librt.func("i", "timer_create", "ipp") - _timer_delete_ = librt.func("i", "timer_delete", "P") - _timer_settime_ = librt.func("i", "timer_settime", "PiPp") - _sigaction_ = libc.func("i", "sigaction", "iPp") - - _sigaction_t = { - "sa_handler": (0 | uctypes.UINT64), - "sa_mask": (8 | uctypes.ARRAY, 16 | uctypes.UINT64), - "sa_flags": (136 | uctypes.INT32), - "sa_restorer": (144 | uctypes.PTR, uctypes.UINT8), - } - _sigval_t = { - "sival_int": 0 | uctypes.INT32, - "sival_ptr": (0 | uctypes.PTR, uctypes.UINT8), - } - _sigevent_t = { - "sigev_value": (0, _sigval_t), - "sigev_signo": uctypes.sizeof(_sigval_t) | uctypes.INT32, - "sigev_notify": (uctypes.sizeof(_sigval_t) + 4) | uctypes.INT32, - "sigev_notify_thread_id": (uctypes.sizeof(_sigval_t) + 8) | uctypes.INT32, - } - _timespec_t = { - "tv_sec": 0 | uctypes.INT32, - "tv_nsec": 8 | uctypes.INT64, - } - _itimerspec_t = { - "it_interval": (0, _timespec_t), - "it_value": (16, _timespec_t), - } - - _SIGRTMIN = libc.func("i", "__libc_current_sigrtmin", "")() - - try: - _gettid = libc.func("i", "gettid", "") - except OSError: - _syscall = libc.func("l", "syscall", "l") - - def _gettid(): - return _syscall(_SYS_gettid) - - def _uctypes_struct(desc): - buf = bytearray(uctypes.sizeof(desc)) - return uctypes.struct(uctypes.addressof(buf), desc, uctypes.NATIVE) - - def _librt_error(name, ret): - if ret != 0: - raise RuntimeError(f"{name} failed (errno={os.errno()})") - - -# --- shared timer + signal helpers (ctypes-style flow) ---------------- - - -def _timer_create(sig_id): - signo = _SIGRTMIN + sig_id - if _USE_CTYPES: - sev = _sigevent() - _apply_sigevent(sev, signo) - timerid = ctypes.c_void_p() - _librt_error( - "timer_create", - librt.timer_create(_CLOCK_MONOTONIC, ctypes.byref(sev), ctypes.byref(timerid)), - ) - return timerid - - sev = _uctypes_struct(_sigevent_t) - _apply_sigevent(sev, signo) - timerid = array.array("P", [0]) - _librt_error("timer_create", _timer_create_(_CLOCK_MONOTONIC, sev, timerid)) - return timerid[0] - - -def _timer_settime(tid, period_ms, periodic): - period_sec, period_ns = _period_parts(period_ms) - spec = _itimerspec() if _USE_CTYPES else _uctypes_struct(_itimerspec_t) - _apply_period(spec, period_sec, period_ns, periodic) - if _USE_CTYPES: - _librt_error("timer_settime", librt.timer_settime(tid, 0, ctypes.byref(spec), None)) - else: - old = _uctypes_struct(_itimerspec_t) - _librt_error("timer_settime", _timer_settime_(tid, 0, spec, old)) - - -def _timer_disarm(tid): - _timer_settime(tid, 0, False) - - -def _timer_delete(tid): - if _USE_CTYPES: - _librt_error("timer_delete", librt.timer_delete(tid)) - else: - _librt_error("timer_delete", _timer_delete_(tid)) - - -def _install_signal(signum, handler): - if _USE_CTYPES: - signal.signal(signum, handler) - return handler - - sa = _uctypes_struct(_sigaction_t) - sa_old = _uctypes_struct(_sigaction_t) - cb = ffi.callback("v", handler, "i", lock=True) - sa.sa_handler = cb.cfun() - _librt_error("sigaction", _sigaction_(signum, sa, sa_old)) - return cb - - -def _remove_signal(signum): - if _USE_CTYPES: - signal.signal(signum, signal.SIG_IGN) - return - - sa = _uctypes_struct(_sigaction_t) - sa_old = _uctypes_struct(_sigaction_t) - # SIG_IGN — absorb any pending RT signal after timer_delete (not SIG_DFL). - sa.sa_handler = 1 - _sigaction_(signum, sa, sa_old) - - -class Timer(_TimerCore): - """Linux librt Timer (timer_create).""" - - def _arm(self): - self._allocated_default_id = self.id == -1 - self.id = _alloc_default_id() if self._allocated_default_id else self.id - signum = _SIGRTMIN + self.id - - def _py_handler(_signum, _frame=None): - self._deliver() - - self._py_handler = _py_handler - self._signal_ref = _install_signal(signum, _py_handler) - self._timer = _timer_create(self.id) - _timer_settime(self._timer, self._period_ms, self._mode == Timer.PERIODIC) - - def _disarm(self): - signum = _SIGRTMIN + (self.id if self.id != -1 else 0xF) - if self._timer is not None: - _timer_disarm(self._timer) - _timer_delete(self._timer) - self._timer = None - _remove_signal(signum) - if getattr(self, "_allocated_default_id", False): - _free_default_id(self.id) - self.id = -1 - self._allocated_default_id = False - self._py_handler = None - self._signal_ref = None diff --git a/lib/multimer/machine.py b/lib/multimer/machine.py deleted file mode 100644 index f6ad116f..00000000 --- a/lib/multimer/machine.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Native ``machine.Timer`` provider.""" - -from machine import Timer - -from . import _provider_pump, _provider_sleep_ms - -name = "machine" -uses_interrupts = True -is_async = False -_defer_sync_arm = False - - -def pump(): - _provider_pump() - - -def sleep_ms(ms): - _provider_sleep_ms(ms, uses_interrupts=True) - - -__all__ = ["Timer", "is_async", "name", "pump", "sleep_ms", "uses_interrupts"] diff --git a/lib/multimer/polling.py b/lib/multimer/polling.py deleted file mode 100644 index fb1c00f5..00000000 --- a/lib/multimer/polling.py +++ /dev/null @@ -1,88 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Brad Barnett -# -# SPDX-License-Identifier: MIT -"""Cooperative polling Timer (last-resort backend).""" - -from . import _provider_pump, _provider_sleep_ms -from ._core import _TimerCore -from ._ticks import ticks_add, ticks_diff, ticks_ms - -_active = [] -name = "polling" -uses_interrupts = False -is_async = False -_defer_sync_arm = True - - -class Timer(_TimerCore): - def _arm(self): - self._next = ticks_add(ticks_ms(), self._period_ms) - if self not in _active: - _active.append(self) - - def _disarm(self): - try: - _active.remove(self) - except ValueError: - pass - - -def _tick(max_items=None): - """Advance due polling timers (internal). Returns callbacks dispatched.""" - if not _active: - return 0 - - now = ticks_ms() - fired = 0 - for timer in tuple(_active): - if max_items is not None and fired >= max_items: - break - if timer not in _active: - continue - if ticks_diff(timer._next, now) > 0: - continue - - fired += 1 - # ``_deliver`` owns hard/soft coalesce; do not call ``_invoke_callback`` - # directly (that skipped soft gap / ``hard``). - timer._deliver() - - if timer._mode == timer.ONE_SHOT or not timer._armed: - break - - timer._next = ticks_add(timer._next, timer._period_ms) - while ticks_diff(timer._next, now) <= 0: - timer._next = ticks_add(timer._next, timer._period_ms) - - return fired - - -def _backend_drain(): - _tick() - - -def _backend_sleep_ms(ms): - """Sleep while pumping due polling timers (CircuitPython / no-thread hosts).""" - import time - - end = ticks_add(ticks_ms(), max(0, int(ms))) - while ticks_diff(end, ticks_ms()) > 0: - _tick() - # coarse yield; supervisor.delay or time.sleep - try: - import supervisor - - supervisor.delay(1) - except Exception: - time.sleep(0.001) - - -def pump(): - _provider_pump(_backend_drain) - - -def sleep_ms(ms): - _provider_sleep_ms(ms, backend_sleep=_backend_sleep_ms, drain=_backend_drain) - - -__all__ = ["Timer", "is_async", "name", "pump", "sleep_ms", "uses_interrupts"] diff --git a/lib/multimer/sdl2.py b/lib/multimer/sdl2.py deleted file mode 100644 index 5b2f3cdf..00000000 --- a/lib/multimer/sdl2.py +++ /dev/null @@ -1,109 +0,0 @@ -# SPDX-FileCopyrightText: 2024 Brad Barnett -# -# SPDX-License-Identifier: MIT -"""SDL2 timer backend using ``usdl2``.""" - -from usdl2 import ( - SDL_INIT_TIMER, - SDL_AddTimer, - SDL_InitSubSystem, - SDL_RemoveTimer, - SDL_TimerCallback, -) - -from . import _provider_pump, _provider_sleep_ms -from ._core import _TimerCore - -_sdl2_timer_inited = False -name = "sdl2" -uses_interrupts = False -is_async = False -_defer_sync_arm = True - - -def _backend_drain(): - import usdl2 - - usdl2.SDL_PumpEvents() - - -def _ensure_sdl2_timer(): - """Initialize the SDL2 timer subsystem once.""" - global _sdl2_timer_inited - if _sdl2_timer_inited: - return - SDL_InitSubSystem(SDL_INIT_TIMER) - _sdl2_timer_inited = True - - -class Timer(_TimerCore): - """Timer backed by SDL_AddTimer.""" - - def __init__(self, id=-1, **kwargs): - self._timer = 0 - self._timer_callback = None - self._handler_ref = None - self._pending = False - super().__init__(id, **kwargs) - - def _arm(self): - _ensure_sdl2_timer() - self._handler_ref = self._handler - self._timer_callback = SDL_TimerCallback(self._handler_ref) - self._timer = SDL_AddTimer(self._period_ms, self._timer_callback, None) - if not self._timer: - self._timer_callback = None - self._handler_ref = None - raise OSError("SDL_AddTimer failed") - - def _disarm(self): - if self._timer: - SDL_RemoveTimer(self._timer) - self._timer = 0 - self._timer_callback = None - self._handler_ref = None - self._pending = False - - def _handler(self, interval, _param=None): - if self._mode is None: - return 0 - # usdl2's SDL trampoline already ``mp_sched_schedule``s onto the VM - # thread before invoking this callback. Do not schedule again — with - # ``hard=False`` (appdev.App) that was a third hop and overflowed - # ``MICROPY_SCHEDULER_DEPTH`` on micropython.exe. - self._pending = False - self._deliver() - if self._mode is None or self._mode == self.ONE_SHOT: - return 0 - return interval - - def _deliver(self): - """Invoke the timer callback directly. - - Soft (``hard=False``) timers normally ``schedule`` again from - ``_TimerCore._deliver``. That is redundant here: usdl2 already - marshalled onto the VM thread, and the extra hop stalls LVGL under - load on micropython.exe. - """ - if self._busy: - return - self._busy = True - self._delivering = True - try: - self._invoke_callback(self) - finally: - self._delivering = False - self._busy = False - if self._mode == self.ONE_SHOT: - self.deinit() - - -def pump(): - _provider_pump(_backend_drain) - - -def sleep_ms(ms): - _provider_sleep_ms(ms, drain=_backend_drain) - - -__all__ = ["Timer", "is_async", "name", "pump", "sleep_ms", "uses_interrupts"] diff --git a/lib/multimer/threading.py b/lib/multimer/threading.py deleted file mode 100644 index 5aba1532..00000000 --- a/lib/multimer/threading.py +++ /dev/null @@ -1,94 +0,0 @@ -# SPDX-FileCopyrightText: 2024 Brad Barnett -# -# SPDX-License-Identifier: MIT -"""Thread-based software Timer.""" - -import sys - -from . import _provider_pump, _provider_sleep_ms -from ._core import _TimerCore -from ._schedule import schedule -from ._ticks import _raw_sleep_ms as _sleep_ms -from ._ticks import ticks_add, ticks_diff, ticks_ms - -name = "threading" -uses_interrupts = False -is_async = False -_defer_sync_arm = False - - -def pump(): - _provider_pump() - - -def sleep_ms(ms): - _provider_sleep_ms(ms) - - -__all__ = ["Timer", "is_async", "name", "pump", "sleep_ms", "uses_interrupts"] - -if sys.implementation.name == "cpython": - import threading - - def _spawn(fn): - threading.Thread(target=fn, daemon=True).start() - -else: - try: - import _thread - - def _spawn(fn): - _thread.start_new_thread(fn, ()) - - except ImportError: - try: - import threading - - def _spawn(fn): - threading.Thread(target=fn, daemon=True).start() - - except ImportError: - raise ImportError("no thread support") from None - - -class Timer(_TimerCore): - def __init__(self, id=-1, **kwargs): - self._running = False - # Pre-bind so the worker can ``schedule`` onto the main thread without - # allocating a bound method each tick (and so soft coalesce runs inside - # ``_deliver`` rather than bypassing it via ``_invoke_callback``). - self._scheduled_deliver = self._run_deliver - super().__init__(id, **kwargs) - - def _wait_idle(self): - # Same reentrancy rule as ``_TimerCore._wait_idle``: ``schedule()`` can - # deliver on the pumping thread, so a self-deinit would spin on the - # ``_busy`` flag its own callback holds. - if self._delivering: - return - while self._busy: - _sleep_ms(1) - - def _arm(self): - self._running = True - _spawn(self._loop) - - def _disarm(self): - self._running = False - - def _run_deliver(self, _arg): - self._deliver() - - def _loop(self): - next_t = ticks_add(ticks_ms(), self._period_ms) - while self._running: - delay = ticks_diff(next_t, ticks_ms()) - if delay > 0: - _sleep_ms(delay) - if not self._running: - break - schedule(self._scheduled_deliver, self) - if self._mode == self.ONE_SHOT: - self._running = False - break - next_t = ticks_add(next_t, self._period_ms) diff --git a/lib/multimer/wasm.py b/lib/multimer/wasm.py deleted file mode 100644 index e24c5322..00000000 --- a/lib/multimer/wasm.py +++ /dev/null @@ -1,55 +0,0 @@ -"""setTimeout/setInterval timer provider for direct WebAssembly.""" - -import _wasm_bridge - -from . import _provider_pump -from ._core import _TimerCore - -_active = {} -_next_id = 1 -name = "wasm" -uses_interrupts = True -is_async = False -_defer_sync_arm = False - - -class Timer(_TimerCore): - def __init__(self, id=-1, **kwargs): - global _next_id - if id < 0: - id = _next_id - _next_id += 1 - super().__init__(id, **kwargs) - - def _arm(self): - _active[self.id] = self - _wasm_bridge.timer_start( - self.id, self._period_ms, self._mode == self.PERIODIC, self._deliver - ) - - def _disarm(self): - _wasm_bridge.timer_cancel(self.id) - _active.pop(self.id, None) - - -def _backend_drain(): - while True: - timer_id = _wasm_bridge.timer_poll() - if timer_id is None: - return - timer = _active.get(timer_id) - if timer is not None: - timer._deliver() - - -def pump(): - _provider_pump(_backend_drain) - - -def sleep_ms(ms): - _provider_pump(_backend_drain) - _wasm_bridge.sleep_ms(max(0, int(ms))) - _provider_pump(_backend_drain) - - -__all__ = ["Timer", "is_async", "name", "pump", "sleep_ms", "uses_interrupts"] diff --git a/lib/multimer/win32.py b/lib/multimer/win32.py deleted file mode 100644 index 94c0ecac..00000000 --- a/lib/multimer/win32.py +++ /dev/null @@ -1,67 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Brad Barnett -# -# SPDX-License-Identifier: MIT -"""Windows waitable-timer backend (CPython + ``uwin32``). - -APCs run on the main thread during an alertable wait. ``_backend_sleep_ms`` -uses ``SleepEx`` so ``uses_interrupts`` is true (librt analogue). -""" - -import uwin32 as win - -from . import _provider_pump, _provider_sleep_ms -from ._core import _TimerCore - -name = "win32" -uses_interrupts = True -is_async = False -_defer_sync_arm = False - - -def _backend_sleep_ms(ms): - win.SleepEx(ms, True) - - -def pump(): - _provider_pump() - - -def sleep_ms(ms): - _provider_sleep_ms(ms, backend_sleep=_backend_sleep_ms, uses_interrupts=True) - - -__all__ = ["Timer", "is_async", "name", "pump", "sleep_ms", "uses_interrupts"] - - -class Timer(_TimerCore): - """Timer backed by ``CreateWaitableTimer`` / ``SetWaitableTimer``.""" - - def __init__(self, id=-1, **kwargs): - self._handle = None - self._apc = None - super().__init__(id, **kwargs) - - def _arm(self): - self._handle = win.CreateWaitableTimerExW() - self._apc = win.TIMERAPCROUTINE(self._on_apc) - period = self._period_ms if self._mode == self.PERIODIC else 0 - win.SetWaitableTimer(self._handle, self._period_ms, period, self._apc, None) - - def _disarm(self): - handle = self._handle - self._handle = None - self._apc = None - if handle: - try: - win.CancelWaitableTimer(handle) - except Exception: - pass - try: - win.CloseHandle(handle) - except Exception: - pass - - def _on_apc(self, _arg, _low, _high): - if self._mode is None: - return - self._deliver() diff --git a/pydevices-desktop.toml b/pydevices-desktop.toml index 4d5f0e11..143e29e5 100644 --- a/pydevices-desktop.toml +++ b/pydevices-desktop.toml @@ -3,7 +3,6 @@ [files] "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/appdev/__init__.py" = "/lib/appdev/__init__.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/appdev/_hostloop.py" = "/lib/appdev/_hostloop.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/appdev/app.py" = "/lib/appdev/app.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/appdev/devices.py" = "/lib/appdev/devices.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/audiodev/__init__.py" = "/lib/audiodev/__init__.py" @@ -59,20 +58,20 @@ "https://raw.githubusercontent.com/PyDevices/pydevices/main/utils/micropython.py" = "/lib/micropython.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/utils/mip.py" = "/lib/mip.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/__init__.py" = "/lib/multimer/__init__.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_async_timer.py" = "/lib/multimer/_async_timer.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_asyncio_loader.py" = "/lib/multimer/_asyncio_loader.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_core.py" = "/lib/multimer/_core.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_dispatch.py" = "/lib/multimer/_dispatch.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_hostloop.py" = "/lib/multimer/_hostloop.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_inputhook.py" = "/lib/multimer/_inputhook.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_mpasyncio.py" = "/lib/multimer/_mpasyncio.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_schedule.py" = "/lib/multimer/_schedule.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_repl.py" = "/lib/multimer/_repl.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_src_asyncio.py" = "/lib/multimer/_src_asyncio.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_src_machine.py" = "/lib/multimer/_src_machine.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_src_native.py" = "/lib/multimer/_src_native.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_src_none.py" = "/lib/multimer/_src_none.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_src_pending.py" = "/lib/multimer/_src_pending.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_src_signal.py" = "/lib/multimer/_src_signal.py" +"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_src_wasm.py" = "/lib/multimer/_src_wasm.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/_ticks.py" = "/lib/multimer/_ticks.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/auto.py" = "/lib/multimer/auto.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/librt.py" = "/lib/multimer/librt.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/machine.py" = "/lib/multimer/machine.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/polling.py" = "/lib/multimer/polling.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/sdl2.py" = "/lib/multimer/sdl2.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/threading.py" = "/lib/multimer/threading.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/wasm.py" = "/lib/multimer/wasm.py" -"https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/multimer/win32.py" = "/lib/multimer/win32.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/utils/usdl2.py" = "/lib/usdl2.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/utils/uwin32.py" = "/lib/uwin32.py" "https://raw.githubusercontent.com/PyDevices/pydevices/main/lib/wifi.py" = "/lib/wifi.py" diff --git a/tests/test_appdev.py b/tests/test_appdev.py index 078bdacc..5baaf80c 100644 --- a/tests/test_appdev.py +++ b/tests/test_appdev.py @@ -181,9 +181,11 @@ def test_every_direct_and_decorator(self): def on_tick(t): hits_dec.append(t) - self.assertEqual(len(self.app._tick_callbacks), 2) + subs = [t for t in self.app.timers if t.name != "app.service"] + self.assertEqual(len(subs), 2) sub.cancel() - self.assertEqual(len(self.app._tick_callbacks), 1) + subs = [t for t in self.app.timers if t.name != "app.service"] + self.assertEqual(len(subs), 1) class TestDeviceAdapters(unittest.TestCase): diff --git a/tests/test_appdev_timers.py b/tests/test_appdev_timers.py new file mode 100644 index 00000000..14f10e6a --- /dev/null +++ b/tests/test_appdev_timers.py @@ -0,0 +1,171 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""appdev.App on multimer: subscriptions, refresh, run() and teardown.""" + +import time +import unittest + +import _env # noqa: F401 + +import multimer +from appdev import App +from multimer import _hostloop + + +class _FakeDisplay: + needs_refresh = True + refresh_period_ms = 20 + + def __init__(self, needs_refresh=True): + self.needs_refresh = needs_refresh + self.shows = 0 + self.quitted = False + + def show(self, timer=None): + self.shows += 1 + + def quit(self): + self.quitted = True + + +def _wait(predicate, timeout_s=2.0): + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if predicate(): + return True + multimer.sleep_ms(5) + return predicate() + + +class TestAppTimers(unittest.TestCase): + def tearDown(self): + app = App.current() + if app is not None: + app._perform_teardown() + multimer.stop_all() + multimer.keepalive(False) + + def test_every_returns_a_multimer_timer(self): + app = App() + hits = [] + tim = app.every(10, lambda t: hits.append(1)) + self.assertIsInstance(tim, multimer.Timer) + self.assertIn(tim, multimer.timers()) + self.assertTrue(_wait(lambda: len(hits) >= 2)) + tim.cancel() + self.assertFalse(tim.running) + + def test_every_as_decorator_and_period_keyword(self): + app = App() + hits = [] + + @app.every(period=10) + def _tick(t): + hits.append(1) + + self.assertTrue(_wait(lambda: len(hits) >= 2)) + + def test_display_refresh_is_a_timer_at_the_display_period(self): + disp = _FakeDisplay() + app = App(displays=[disp]) + names = [t.name for t in app.timers] + self.assertIn("refresh:_FakeDisplay", names) + refresh = [t for t in app.timers if t.name == "refresh:_FakeDisplay"][0] + self.assertEqual(20, refresh.period) + self.assertTrue(_wait(lambda: disp.shows >= 2)) + + def test_refresh_pause_and_resume(self): + disp = _FakeDisplay() + app = App(displays=[disp]) + _wait(lambda: disp.shows >= 1) + with app.refresh_paused(): + n = disp.shows + multimer.sleep_ms(60) + self.assertEqual(n, disp.shows) + self.assertTrue(_wait(lambda: disp.shows > n)) + + def test_an_app_keeps_the_process_alive(self): + App(displays=[_FakeDisplay()]) + self.assertTrue(multimer.alive()) + + def test_quit_tears_down_and_releases_keepalive(self): + disp = _FakeDisplay() + app = App(displays=[disp]) + hits = [] + + @app.every(10) + def _tick(t): + hits.append(1) + if len(hits) == 2: + app.request_quit() + + self.assertTrue(_wait(lambda: app._teardown_done)) + self.assertTrue(disp.quitted) + self.assertFalse(multimer.alive()) + self.assertEqual((), app.timers) + + def test_run_blocks_until_quit(self): + disp = _FakeDisplay() + app = App(displays=[disp]) + ticks = [] + + @app.every(15) + def on_tick(t): + ticks.append(1) + if len(ticks) >= 3: + app.request_quit() + + app.run() + self.assertTrue(app.quit_requested) + self.assertTrue(disp.quitted) + self.assertGreaterEqual(len(ticks), 3) + + def test_run_returns_at_once_under_an_ambient_host(self): + app = App(displays=[_FakeDisplay()]) + original = _hostloop._state["strategy"] + _hostloop._state["strategy"] = _hostloop.AMBIENT + try: + t0 = time.monotonic() + app.run() + self.assertLess(time.monotonic() - t0, 0.5) + finally: + _hostloop._state["strategy"] = original + + def test_exit_code_from_run(self): + app = App(displays=[_FakeDisplay()]) + app.every(5, lambda t: app.request_quit(3)) + with self.assertRaises(SystemExit) as cm: + app.run() + self.assertEqual(3, cm.exception.code) + + def test_second_app_stops_the_first(self): + first = App(displays=[_FakeDisplay()]) + hits = [] + first.every(5, lambda t: hits.append(1)) + second = App() + self.assertIs(App.current(), second) + self.assertEqual((), first.timers) + n = len(hits) + multimer.sleep_ms(30) + self.assertEqual(n, len(hits), "the first app's timers must stop dispatching") + + def test_run_async_inside_a_loop_schedules_a_task(self): + import asyncio + + app = App(displays=[_FakeDisplay()]) + seen = [] + + async def main(): + seen.append(1) + + async def host(): + task = app.run_async(main) + await task + + asyncio.run(host()) + self.assertEqual([1], seen) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_async_and_sync_timers.py b/tests/test_async_and_sync_timers.py deleted file mode 100644 index 2c21cfcd..00000000 --- a/tests/test_async_and_sync_timers.py +++ /dev/null @@ -1,246 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Brad Barnett -# -# SPDX-License-Identifier: MIT -"""Dedicated test suite validating async and synchronous timer backends in appdev.""" - -import time -import unittest - -import _env # noqa: F401 - -import events -import keys -import appdev -from appdev import App -from multimer import AsyncTimer, auto as timer - - -class _FakeDisplay: - def __init__(self, needs_refresh=True): - self.needs_refresh = needs_refresh - self.shows = 0 - self.quitted = False - - def show(self, timer=None): - self.shows += 1 - - def quit(self): - self.quitted = True - - -def _wait(predicate, timeout_s=1.5): - deadline = time.monotonic() + timeout_s - while time.monotonic() < deadline: - if predicate(): - return True - timer.sleep_ms(5) - return predicate() - - -class TestSyncTimers(unittest.TestCase): - """Tests for synchronous timer backends (machine, librt, win32, sdl2, threading, polling).""" - - def setUp(self): - self.app = App(timer_async=False) - - def tearDown(self): - self.app._perform_teardown() - - def test_sync_timer_type(self): - self.assertFalse(self.app.timer_async) - self.assertIsNone(self.app._timer) - self.app.every(20, lambda t: None) - self.assertIsNotNone(self.app._timer) - self.assertNotIsInstance(self.app._timer, AsyncTimer) - - def test_sync_timer_periodic_dispatch(self): - hits = [] - self.app.every(10, lambda t: hits.append(1)) - self.assertTrue(_wait(lambda: len(hits) >= 2), f"Expected >= 2 hits, got {len(hits)}") - - def test_sync_display_auto_refresh(self): - disp = _FakeDisplay(needs_refresh=True) - app = App(displays=[disp], timer_async=False) - self.addCleanup(app._perform_teardown) - self.assertTrue(_wait(lambda: disp.shows >= 1), "display.show never called in sync mode") - - def test_sync_run_blocking_and_quit(self): - disp = _FakeDisplay(needs_refresh=True) - app = App(displays=[disp], timer_async=False) - self.addCleanup(app._perform_teardown) - - ticks = [] - - @app.every(15) - def on_tick(t): - ticks.append(len(ticks)) - if len(ticks) >= 3: - app.request_quit() - - app.run() - self.assertTrue(app.quit_requested) - self.assertTrue(disp.quitted) - self.assertTrue(len(ticks) >= 3) - - -class TestDeferredSyncArm(unittest.TestCase): - """``_defer_sync_arm`` defers the *refresh subscription*, not every timer. - - Folding that flag into the general "can I arm?" predicate left ``app._timer`` - None all the way through UI construction on sdl2 (CircuitPython desktop), - which broke callers that inspect it. - """ - - def _app(self, defer): - name = "_sync_refresh_needs_deferred_arm" - real = App.__dict__[name] - App._sync_refresh_needs_deferred_arm = staticmethod(lambda: defer) - self.addCleanup(setattr, App, name, real) - app = App(displays=[_FakeDisplay(needs_refresh=True)], timer_async=False) - self.addCleanup(app._perform_teardown) - return app - - def test_sync_timer_arms_immediately_even_when_refresh_defers(self): - app = self._app(defer=True) - app.every(20, lambda t: None) - self.assertIsNotNone(app._timer, "sync timers must not wait for the loop") - self.assertTrue(app._refresh_pending, "the refresh subscription still defers") - - def test_refresh_arms_immediately_without_the_flag(self): - app = self._app(defer=False) - self.assertIsNotNone(app._timer) - self.assertFalse(app._refresh_pending) - - -class TestQuitFromCallback(unittest.TestCase): - """Quitting inside a tick callback must still tear down. - - Teardown is deferred only when a loop is actually running. MicroPython's - create_task succeeds with no loop running (verified on an ESP32-P4), so - using it as the probe queued teardown onto a queue nothing serviced and the - app never tore down. - """ - - def test_no_running_loop_tears_down_immediately(self): - app = App(displays=[_FakeDisplay()], timer_async=False) - self.addCleanup(app._perform_teardown) - self.assertFalse(app._event_loop_running(), "no loop in this test") - self.assertFalse( - app._schedule_async_teardown(), - "must not defer when no loop will ever run it", - ) - - def test_quit_inside_tick_callback_completes_teardown(self): - disp = _FakeDisplay(needs_refresh=True) - app = App(displays=[disp], timer_async=False) - self.addCleanup(app._perform_teardown) - hits = [] - - @app.every(10) - def _tick(_t): - hits.append(1) - if len(hits) == 2: - app.request_quit() - - self.assertTrue(_wait(lambda: app._teardown_done, 2.0), "teardown never ran") - self.assertTrue(disp.quitted, "display was never released") - - -class TestAsyncTimers(unittest.TestCase): - """Tests for asynchronous timer backends (AsyncTimer / asyncio).""" - - def test_async_timer_defers_start_until_loop_running(self): - app = App(timer_async=True) - self.assertTrue(app.timer_async) - self.assertIsNone(app._timer) - - hits = [] - app.every(20, hits.append) - # Timer creation is deferred because event loop is not running yet - self.assertIsNone(app._timer) - self.assertTrue(app._deferred) - - # Once the deferred work flushes inside an event loop, the timer starts - import asyncio - - async def _test(): - app._flush_deferred() - self.assertIsNotNone(app._timer) - self.assertIsInstance(app._timer, AsyncTimer) - # Wait for hits - deadline = time.monotonic() + 1.0 - while time.monotonic() < deadline: - if len(hits) >= 2: - return - await asyncio.sleep(0.02) - raise AssertionError(f"AsyncTimer never fired, hits: {len(hits)}") - - asyncio.run(_test()) - app._perform_teardown() - - def test_async_display_refresh(self): - disp = _FakeDisplay(needs_refresh=True) - app = App(displays=[disp], timer_async=True) - self.assertIsNone(app._timer) - self.assertTrue(app._refresh_pending) - - import asyncio - - async def _test(): - app._flush_deferred() - deadline = time.monotonic() + 1.0 - while time.monotonic() < deadline: - if disp.shows >= 1: - return - await asyncio.sleep(0.02) - raise AssertionError("display.show never called in async mode") - - asyncio.run(_test()) - app._perform_teardown() - - def test_async_run_standalone(self): - """app.run() in timer_async mode automatically invokes asyncio.run().""" - disp = _FakeDisplay(needs_refresh=True) - app = App(displays=[disp], timer_async=True) - ticks = [] - - @app.every(15) - def on_tick(t): - ticks.append(len(ticks)) - if len(ticks) >= 3: - app.request_quit() - - # app.run() handles asyncio loop startup and teardown transparently - app.run() - - self.assertTrue(app.quit_requested) - self.assertTrue(disp.quitted) - self.assertTrue(len(ticks) >= 3, f"Expected >= 3 ticks, got {len(ticks)}") - - def test_async_run_inside_running_loop(self): - """app.run() inside an existing loop (PyScript/Jupyter) arms and returns.""" - disp = _FakeDisplay(needs_refresh=True) - app = App(displays=[disp], timer_async=True) - import asyncio - - async def _host_loop(): - # In an already-running async host (e.g. PyScript / Jupyter), app.run() - # arms the timer/refresh and returns immediately without blocking. - app.run() - self.assertIsNotNone(app._timer) - self.assertIsInstance(app._timer, AsyncTimer) - # Host loop ticks along - deadline = time.monotonic() + 1.0 - while time.monotonic() < deadline: - if disp.shows >= 1: - break - await asyncio.sleep(0.02) - self.assertTrue(disp.shows >= 1) - app.request_quit() - - asyncio.run(_host_loop()) - app._perform_teardown() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_autodisplay.py b/tests/test_autodisplay.py index 9acf057e..bc1de56d 100644 --- a/tests/test_autodisplay.py +++ b/tests/test_autodisplay.py @@ -42,7 +42,6 @@ class TestAutoDisplay(unittest.TestCase): def test_pyscript_returns_display(self): display = mock.Mock(name="PSDisplay") display.get_events = mock.Mock(name="ps_get_events") - display.requires_async_timer = True ps_mod = types.ModuleType("displaydev.psdisplay") ps_mod.PSDisplay = mock.Mock(return_value=display) with mock.patch.object(ad, "host_kind", return_value="pyscript"), mock.patch.dict( @@ -50,14 +49,12 @@ def test_pyscript_returns_display(self): ): result = AutoDisplay(width=100, height=200, canvas_id="c1", quiet=True) self.assertIs(result, display) - self.assertTrue(result.requires_async_timer) self.assertIs(result.get_events, display.get_events) ps_mod.PSDisplay.assert_called_once_with("c1", 100, 200, quiet=True) def test_jupyter_returns_display(self): display = mock.Mock(name="JNDisplay") display.get_events = mock.Mock(name="jn_get_events") - display.requires_async_timer = True jn_mod = types.ModuleType("displaydev.jndisplay") jn_mod.JNDisplay = mock.Mock(return_value=display) with mock.patch.object(ad, "host_kind", return_value="jupyter"), mock.patch.dict( @@ -65,13 +62,11 @@ def test_jupyter_returns_display(self): ): result = AutoDisplay(width=80, height=60, quiet=True) self.assertIs(result, display) - self.assertTrue(result.requires_async_timer) jn_mod.JNDisplay.assert_called_once_with(80, 60, quiet=True) def test_desktop_pg_first(self): display = mock.Mock(name="PGDisplay") display.get_events = mock.Mock(name="pg_get_events") - display.requires_async_timer = False pg_mod = types.ModuleType("displaydev.pgdisplay") pg_mod.PGDisplay = mock.Mock(return_value=display) with mock.patch.object(ad, "host_kind", return_value="desktop"), mock.patch.object( @@ -86,7 +81,6 @@ def test_desktop_pg_first(self): quiet=True, ) self.assertIs(result, display) - self.assertFalse(result.requires_async_timer) self.assertIs(result.get_events, display.get_events) pg_mod.PGDisplay.assert_called_once_with( width=320, @@ -100,7 +94,6 @@ def test_desktop_pg_first(self): def test_desktop_falls_back_to_sdl(self): display = mock.Mock(name="SDLDisplay") display.get_events = mock.Mock(name="sdl_get_events") - display.requires_async_timer = False sdl_mod = types.ModuleType("displaydev.sdldisplay") sdl_mod.SDLDisplay = mock.Mock(return_value=display) with mock.patch.object(ad, "host_kind", return_value="desktop"), mock.patch.object( @@ -116,7 +109,6 @@ def test_desktop_falls_back_to_sdl(self): quiet=True, ) self.assertIs(result, display) - self.assertFalse(result.requires_async_timer) self.assertIs(result.get_events, display.get_events) sdl_mod.SDLDisplay.assert_called_once() kwargs = sdl_mod.SDLDisplay.call_args.kwargs @@ -127,7 +119,6 @@ def test_desktop_falls_back_to_sdl(self): def test_win32_prefers_windisplay(self): display = mock.Mock(name="WinDisplay") display.get_events = mock.Mock() - display.requires_async_timer = False win_mod = types.ModuleType("displaydev.windisplay") win_mod.WinDisplay = mock.Mock(return_value=display) pg_mod = types.ModuleType("displaydev.pgdisplay") @@ -146,7 +137,6 @@ def test_win32_prefers_windisplay(self): def test_win32_sets_directsound_when_windisplay_unavailable(self): display = mock.Mock(name="PGDisplay") display.get_events = mock.Mock() - display.requires_async_timer = False pg_mod = types.ModuleType("displaydev.pgdisplay") pg_mod.PGDisplay = mock.Mock(return_value=display) with mock.patch.object(ad, "host_kind", return_value="desktop"), mock.patch.object( @@ -164,7 +154,6 @@ def test_win32_sets_directsound_when_windisplay_unavailable(self): def test_win32_skips_directsound_for_pyscript(self): display = mock.Mock(name="PSDisplay") display.get_events = mock.Mock() - display.requires_async_timer = True ps_mod = types.ModuleType("displaydev.psdisplay") ps_mod.PSDisplay = mock.Mock(return_value=display) with mock.patch.object(ad, "host_kind", return_value="pyscript"), mock.patch.object( @@ -178,7 +167,6 @@ def test_win32_skips_directsound_for_pyscript(self): def test_android_uses_shown_highdpi_flags(self): display = mock.Mock(name="AndroidSDLDisplay") display.get_events = mock.Mock(name="sdl_get_events") - display.requires_async_timer = False android_mod = types.ModuleType("displaydev.androidsdl") android_mod.AndroidSDLDisplay = mock.Mock(return_value=display) usdl2_mod = types.ModuleType("usdl2") diff --git a/tests/test_desktop_board_config.py b/tests/test_desktop_board_config.py index 85a8b336..9f20d2de 100644 --- a/tests/test_desktop_board_config.py +++ b/tests/test_desktop_board_config.py @@ -47,7 +47,6 @@ def test_eager_mcu_shape_with_mocked_autodisplay(self): display.needs_refresh = True display.fill = mock.Mock() display.get_events = mock.Mock(name="get_events") - display.requires_async_timer = False displaydev_mod = types.ModuleType("displaydev") displaydev_mod.env_bool = lambda name, default=False: default displaydev_mod.env_float = lambda name, default=0.0: default @@ -67,7 +66,6 @@ def test_eager_mcu_shape_with_mocked_autodisplay(self): self.assertIs(board_config.display_drv, display) self.assertIs(board_config.host_read, display.get_events) - self.assertFalse(board_config.timer_async) self.assertFalse(hasattr(board_config, "app")) self.assertEqual(board_config.PERIPHERALS, frozenset({"audio_out", "pcm_out", "pcm_in"})) import board_peripherals diff --git a/tests/test_hostloop.py b/tests/test_hostloop.py index 16db9aef..fb589b2d 100644 --- a/tests/test_hostloop.py +++ b/tests/test_hostloop.py @@ -1,143 +1,107 @@ # SPDX-FileCopyrightText: 2026 Brad Barnett # # SPDX-License-Identifier: MIT -"""appdev._hostloop: who owns the main thread after the script body ends.""" +"""multimer._hostloop: who owns the main thread after the script body ends.""" import unittest import _env # noqa: F401 -from appdev import _hostloop - - -class _Harness: - """Records what hostloop asked of its owner.""" - - def __init__(self, ticks=3): - self.ticks = ticks - self.pumped = 0 - self.started = 0 - self.stopped = 0 - self.drove = 0 - - def pump(self): - self.pumped += 1 - - def alive(self): - return self.pumped < self.ticks - - def on_start(self): - self.started += 1 - - def on_stop(self): - self.stopped += 1 - - def drive(self): - self.drove += 1 - - def install(self, **kwargs): - kwargs.setdefault("pump", self.pump) - kwargs.setdefault("alive", self.alive) - kwargs.setdefault("on_start", self.on_start) - kwargs.setdefault("on_stop", self.on_stop) - return _hostloop.install(**kwargs) +import multimer +from multimer import _dispatch, _hostloop class TestHostloop(unittest.TestCase): def setUp(self): _hostloop._reset_for_test() self.addCleanup(_hostloop._reset_for_test) + self.addCleanup(multimer.stop_all) + self.addCleanup(multimer.keepalive, False) def _force(self, strategy): _hostloop._state["strategy"] = strategy - def test_strategy_is_none_before_install(self): - self.assertIsNone(_hostloop.strategy()) + def _patch(self, obj, name, value): + original = getattr(obj, name) + setattr(obj, name, value) + self.addCleanup(setattr, obj, name, original) - def test_install_decides_once_and_rebinds(self): - first = _Harness() - chosen = first.install() - self.assertIn(chosen, (_hostloop.AMBIENT, _hostloop.EXIT_HOOK, _hostloop.NONE)) - second = _Harness() - self.assertEqual(chosen, second.install()) - self.assertIs(_hostloop._state["pump"].__self__, second) + def test_strategy_is_none_before_a_timer_is_armed(self): + self.assertIsNone(_hostloop.strategy()) def test_batch_entry_point_declines_to_drive(self): """``-m`` / ``-c`` is a test runner or a one-liner, never an app.""" self.assertTrue(_hostloop.batch(), "the test suite itself runs under -m") - self.assertEqual(_hostloop.NONE, _Harness().install()) - - def test_exit_hook_pumps_until_not_alive(self): - h = _Harness(ticks=4) - h.install() + self.assertEqual(_hostloop.NONE, _hostloop.ensure_installed()) + + def test_arming_a_timer_decides_once(self): + with multimer.every(1000, lambda t: None): + first = _hostloop.strategy() + self.assertIn(first, (_hostloop.AMBIENT, _hostloop.EXIT_HOOK, _hostloop.NONE)) + with multimer.every(1000, lambda t: None): + self.assertEqual(first, _hostloop.strategy()) + + def test_exit_hook_delivers_until_not_alive(self): + count = [] + + def tick(t): + count.append(1) + if len(count) >= 4: + t.deinit() + + multimer.keepalive() + multimer.every(5, tick) + stopped = [] + _hostloop.on_stop(lambda: stopped.append(1)) self._force(_hostloop.EXIT_HOOK) _hostloop._exit_hook() - self.assertEqual(1, h.started) - self.assertEqual(4, h.pumped) - self.assertEqual(1, h.stopped) + self.assertEqual(4, len(count)) + self.assertEqual(1, len(stopped)) + self.assertFalse(multimer.alive()) def test_claim_suppresses_the_hook_loop(self): - h = _Harness() - h.install() + count = [] + multimer.keepalive() + multimer.every(5, lambda t: count.append(1)) + stopped = [] + _hostloop.on_stop(lambda: stopped.append(1)) self._force(_hostloop.EXIT_HOOK) _hostloop.claim() _hostloop._exit_hook() - self.assertEqual(0, h.pumped, "run() and the hook must never both drive") - self.assertEqual(1, h.stopped, "teardown still happens") + self.assertEqual(0, len(count), "run_until() and the hook must never both drive") + self.assertEqual(1, len(stopped), "teardown still happens") def test_crashed_script_does_not_enter_the_loop(self): - h = _Harness() - h.install() + count = [] + multimer.keepalive() + multimer.every(5, lambda t: count.append(1)) self._force(_hostloop.EXIT_HOOK) _hostloop.mark_crashed() _hostloop._exit_hook() - self.assertEqual(0, h.pumped) - self.assertEqual(1, h.stopped) + self.assertEqual(0, len(count)) - def test_drive_replaces_the_sync_pump_loop(self): - """Async apps must not be driven by a loop that never runs asyncio.""" - h = _Harness() - h.install(drive=h.drive) + def test_no_keepalive_means_the_hook_returns_at_once(self): + """A bare timer script behaves like a daemon thread.""" + count = [] + multimer.every(5, lambda t: count.append(1)) + self.assertFalse(multimer.alive()) self._force(_hostloop.EXIT_HOOK) _hostloop._exit_hook() - self.assertEqual(1, h.drove) - self.assertEqual(0, h.pumped) - - def test_quit_tears_down_under_ambient_only(self): - h = _Harness() - h.install() - self._force(_hostloop.EXIT_HOOK) - _hostloop.quit() - self.assertEqual(0, h.stopped, "the exit_hook loop notices via alive()") - self._force(_hostloop.AMBIENT) - _hostloop.quit() - self.assertEqual(1, h.stopped, "nothing else would ever notice") + self.assertEqual(0, len(count)) def test_stop_runs_once(self): - h = _Harness() - h.install() - self._force(_hostloop.AMBIENT) - _hostloop.quit() - _hostloop.quit() - self.assertEqual(1, h.stopped) + stopped = [] + _hostloop.on_stop(lambda: stopped.append(1)) + _hostloop._stop() + _hostloop._stop() + self.assertEqual(1, len(stopped)) # -- MCU firmware lifecycle ------------------------------------------------ - # - # These probes decide whether an app that never calls run() stays alive on a - # board. The example matrix cannot reach them: it exercises CircuitPython - # only through the unix build, where sys.platform is "linux" and _mcu() is - # False, so the real firmware path went untested until it failed on an - # RP2040. def _fake_impl(self, name, platform): self._patch(_hostloop, "_impl", lambda: name) self._patch(_hostloop.sys, "platform", platform) - def _patch(self, obj, name, value): - original = getattr(obj, name) - setattr(obj, name, value) - self.addCleanup(setattr, obj, name, original) - def test_mcu_is_false_for_desktop_builds(self): for impl in ("micropython", "circuitpython"): for platform in ("linux", "win32", "darwin"): @@ -146,55 +110,35 @@ def test_mcu_is_false_for_desktop_builds(self): self.assertFalse(_hostloop._mcu()) def test_micropython_firmware_is_ambient(self): - # After main.py MicroPython drops to a REPL that keeps hardware timers - # delivering, so returning from the script body is survivable. self._fake_impl("micropython", "rp2") self.assertTrue(_hostloop.ambient()) def test_circuitpython_firmware_is_not_ambient(self): - # CircuitPython's supervisor resets the port after code.py returns, so - # there is no ambient loop to inherit -- it must drive its own. self._fake_impl("circuitpython", "rp2") self.assertFalse(_hostloop.ambient()) def test_circuitpython_firmware_is_never_interactive(self): - # CircuitPython cannot distinguish code.py from the REPL -- __main__ has - # neither __file__ nor __name__ in either, and run_reason describes what - # triggered the last code.py run, not what is running now. Since no - # timers are delivered in the background either way, the exit hook has - # to own the loop in both cases. self._fake_impl("circuitpython", "rp2") self.assertFalse(_hostloop.interactive()) def test_circuitpython_firmware_takes_the_exit_hook(self): - # The whole point: an app that never calls run() has to be driven by - # something. On CircuitPython that is the atexit hook. self._fake_impl("circuitpython", "rp2") self._patch(_hostloop, "_cmdline_tokens", lambda: ()) self._patch(_hostloop, "on_exit", lambda fn: True) - self.assertEqual(_hostloop.EXIT_HOOK, _Harness().install()) + self.assertEqual(_hostloop.EXIT_HOOK, _hostloop.ensure_installed()) def test_pump_loop_stops_on_circuitpython_ctrl_c(self): # Without this the board wedges: nothing drains CircuitPython's serial - # ring inside an atexit handler, so host writes block and Ctrl-C -- which - # is plain stdin data there, not an interrupt -- can never land. - h = _Harness(ticks=10_000) - self._patch(_hostloop, "_state", dict(_hostloop._state)) - _hostloop._state["pump"] = h.pump - _hostloop._state["alive"] = h.alive + # ring inside an atexit handler, so host writes block and Ctrl-C -- + # plain stdin data there, not an interrupt -- can never land. + multimer.keepalive() + count = [] + multimer.every(1, lambda t: count.append(1)) presses = [False, False, True] self._patch(_hostloop, "_cp_break_watch", lambda: lambda: presses.pop(0)) _hostloop._run_loop() - self.assertEqual(3, h.pumped, "loop must stop on the press, not run to alive()") - - def test_pump_loop_untouched_without_a_break_watch(self): - h = _Harness(ticks=3) - self._patch(_hostloop, "_state", dict(_hostloop._state)) - _hostloop._state["pump"] = h.pump - _hostloop._state["alive"] = h.alive - self._patch(_hostloop, "_cp_break_watch", lambda: None) - _hostloop._run_loop() - self.assertEqual(3, h.pumped) + self.assertTrue(multimer.alive(), "loop must stop on the press, not run to alive()") + self.assertEqual(0, len(presses)) def test_break_watch_is_none_off_circuitpython_firmware(self): self._fake_impl("micropython", "rp2") @@ -204,9 +148,7 @@ def test_break_watch_is_none_off_circuitpython_firmware(self): def test_utf16le_decodes_without_a_codec(self): raw = 'mp.exe -m examples.google_photos "C:\\Café"'.encode("utf-16-le") - self.assertEqual( - _hostloop._utf16le(raw), 'mp.exe -m examples.google_photos "C:\\Café"' - ) + self.assertEqual(_hostloop._utf16le(raw), 'mp.exe -m examples.google_photos "C:\\Café"') self.assertEqual(_hostloop._utf16le("a\U0001F600b".encode("utf-16-le")), "a??b") def test_split_cmdline_handles_quotes(self): @@ -216,5 +158,21 @@ def test_split_cmdline_handles_quotes(self): ) +class TestDispatchAliveContract(unittest.TestCase): + def tearDown(self): + multimer.stop_all() + multimer.keepalive(False) + + def test_alive_needs_keepalive_and_a_timer(self): + self.assertFalse(multimer.alive()) + multimer.keepalive() + self.assertFalse(multimer.alive(), "keepalive with nothing armed is not alive") + t = multimer.every(1000, lambda t: None) + self.assertTrue(multimer.alive()) + t.deinit() + self.assertFalse(multimer.alive()) + self.assertEqual((), _dispatch.timers()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_multimer.py b/tests/test_multimer.py index 4fc8c5c0..87318d6e 100644 --- a/tests/test_multimer.py +++ b/tests/test_multimer.py @@ -1,564 +1,479 @@ # SPDX-FileCopyrightText: 2026 Brad Barnett # # SPDX-License-Identifier: MIT +"""multimer: the Timer contract, the dispatcher's rules, and the failure +classes that cost real time before the redesign. +The wake source is chosen once per process, so the tests that need a named +source run a child interpreter with ``MULTIMER_SOURCE`` set. +""" + +import io import os -import runpy +import subprocess import sys import threading import time import unittest import _env # noqa: F401 + import multimer -from multimer import ( - AsyncTimer, - monotonic, - ticks_add, - ticks_diff, - ticks_less, - ticks_ms, -) -from multimer import auto as timer - -Timer = timer.Timer -sleep_ms = timer.sleep_ms +from multimer import Timer, ticks_add, ticks_diff, ticks_less, ticks_ms _TICKS_PERIOD = 1 << 29 _TICKS_MAX = _TICKS_PERIOD - 1 _TICKS_HALFPERIOD = _TICKS_PERIOD // 2 -_PUBLIC_TIMER_MEMBERS = {"init", "deinit", "ONE_SHOT", "PERIODIC"} - - -def _public_class_members(cls): - return {n for n in dir(cls) if not n.startswith("_")} +_PUBLIC_TIMER_MEMBERS = { + "init", + "deinit", + "cancel", + "ONE_SHOT", + "PERIODIC", + "period", + "mode", + "callback", + "running", + "due_in", + "fired", + "missed", + "late_max", + "last", + "error", + "name", + "id", + "yield_cap", + "reschedule", +} + + +def _wait(predicate, timeout_s=2.0): + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if predicate(): + return True + multimer.sleep_ms(2) + return predicate() + + +def _child(code, source=None, timeout=30): + env = dict(os.environ) + if source is not None: + env["MULTIMER_SOURCE"] = source + env["PYTHONPATH"] = os.pathsep.join(sys.path) + p = subprocess.run([sys.executable, "-c", code], env=env, capture_output=True, text=True, timeout=timeout) + return p class TestApiSurface(unittest.TestCase): - def test_timer_public_members(self): - self.assertEqual(_public_class_members(Timer), _PUBLIC_TIMER_MEMBERS) + def tearDown(self): + multimer.stop_all() - def test_async_timer_public_members(self): - self.assertEqual(_public_class_members(AsyncTimer), _PUBLIC_TIMER_MEMBERS) + def test_timer_public_members(self): + members = {n for n in dir(Timer(-1)) if not n.startswith("_")} + self.assertEqual(members, _PUBLIC_TIMER_MEMBERS) def test_constants_match_micropython(self): self.assertEqual(Timer.ONE_SHOT, 0) self.assertEqual(Timer.PERIODIC, 1) - self.assertEqual(AsyncTimer.ONE_SHOT, 0) - self.assertEqual(AsyncTimer.PERIODIC, 1) def test_package_exports(self): - self.assertEqual( - set(multimer.__all__), - { - "AsyncTimer", - "loop_running", - "monotonic", - "run_deadline_hook", - "schedule", - "set_deadline_hook", - "ticks_ms", - "ticks_add", - "ticks_diff", - "ticks_less", - "asyncio", - }, - ) - - -class TestProviderSelection(unittest.TestCase): - def test_root_has_no_timer_or_backend_side_effects(self): - import subprocess - - code = ( - "import sys; sys.path.insert(0, 'lib'); import multimer; " - "assert not any(hasattr(multimer, n) for n in (" - "'Timer','sleep_ms','backends','available_backends'," - "'backends_available','use_backend')); " - "assert not any(n in sys.modules for n in (" - "'multimer.auto','multimer.machine','multimer.librt','multimer.win32'," - "'multimer.sdl2','multimer.threading','multimer.polling'))" - ) - subprocess.run([sys.executable, "-c", code], check=True) - - def test_explicit_provider_contract(self): - from multimer import polling - - self.assertEqual( - set(polling.__all__), - {"Timer", "is_async", "name", "pump", "sleep_ms", "uses_interrupts"}, - ) - self.assertEqual(polling.name, "polling") - self.assertFalse(polling.uses_interrupts) - self.assertFalse(polling.is_async) - self.assertEqual(polling.Timer.__module__, "multimer.polling") - - def test_environment_forces_auto_provider_at_import(self): - import subprocess - - code = ( - "import sys; sys.path.insert(0, 'lib'); " - "from multimer import auto as timer; " - "assert timer.name == 'polling'; " - "assert timer.Timer.__module__ == 'multimer.polling'" - ) - env = os.environ.copy() - env["MULTIMER_BACKEND"] = "polling" - subprocess.run([sys.executable, "-c", code], check=True, env=env) - - def test_environment_can_force_async_auto_provider(self): - import subprocess - - code = ( - "import sys; sys.path.insert(0, 'lib'); " - "from multimer import AsyncTimer; from multimer import auto as timer; " - "assert timer.name == 'async'; assert timer.Timer is AsyncTimer; " - "assert timer.is_async and not timer.uses_interrupts" - ) - env = os.environ.copy() - env["MULTIMER_BACKEND"] = "async" - subprocess.run([sys.executable, "-c", code], check=True, env=env) - - def test_invalid_environment_backend_fails_without_fallback(self): - import subprocess - - code = ( - "import sys; sys.path.insert(0, 'lib'); " - "from multimer import auto" - ) - env = os.environ.copy() - env["MULTIMER_BACKEND"] = "no_such_backend" - result = subprocess.run( - [sys.executable, "-c", code], - env=env, - capture_output=True, - text=True, - check=False, - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn("unknown multimer backend", result.stderr) - - def test_auto_matches_provider_contract(self): - self.assertEqual( - set(timer.__all__), - {"Timer", "is_async", "name", "pump", "sleep_ms", "uses_interrupts"}, - ) - self.assertIsInstance(timer.name, str) - self.assertIsInstance(timer.uses_interrupts, bool) - self.assertIsInstance(timer.is_async, bool) - - def test_auto_backends_skips_win32_off_windows(self): - from unittest import mock - - from multimer import auto - - with mock.patch.object(auto.sys, "platform", "linux"): - self.assertNotIn("win32", auto._auto_backends()) - - @unittest.skipUnless(sys.platform == "win32", "win32 timer backend") - def test_win32_backend_arms(self): - from multimer import win32 - + for name in ("Timer", "every", "after", "sleep_ms", "pump", "schedule", "hold", + "keepalive", "run_until", "timers", "info", "report", "repl", + "ticks_ms", "ticks_us", "ticks_diff", "ticks_add", "ticks_less", + "monotonic", "asleep_ms", "loop_running", "strategy"): + self.assertTrue(hasattr(multimer, name), name) + for gone in ("auto", "AsyncTimer", "uses_interrupts", "is_async", "librt", "polling"): + self.assertFalse(hasattr(multimer, gone), gone) + + def test_import_arms_nothing(self): + # A fresh interpreter that only imports the package selects no source. + p = _child("import multimer, sys; print(multimer.info()['source'])") + self.assertEqual("None", p.stdout.strip(), p.stderr) + + def test_constructor_kwargs_match_machine_timer(self): hits = [] - self.assertTrue(win32.uses_interrupts) - t = win32.Timer(-1) - t.init(period=40, callback=lambda _t: hits.append(1)) - try: - win32.sleep_ms(120) - finally: - t.deinit() - self.assertGreaterEqual(len(hits), 1) - - def test_auto_backends_skips_sdl2_when_pygame_present(self): - from unittest import mock + tim = Timer(-1, mode=Timer.PERIODIC, period=5, callback=hits.append) + self.addCleanup(tim.deinit) + self.assertTrue(tim.running) + self.assertEqual(5, tim.period) + self.assertTrue(_wait(lambda: len(hits) >= 2)) + self.assertIs(hits[0], tim) - from multimer import auto - - self.assertEqual(sys.implementation.name, "cpython") - with mock.patch.object(auto, "_pygame_available", return_value=True): - self.assertNotIn("sdl2", auto._auto_backends()) - # With pygame available, auto-selection must not land on sdl2. - with mock.patch.object(auto, "_pygame_available", return_value=True): - # Re-evaluate active backend through the same auto filter used at import. - candidates = auto._auto_backends() - self.assertNotIn("sdl2", candidates) - - def test_auto_backends_allows_sdl2_on_cpython_without_pygame(self): - from unittest import mock - - from multimer import auto - - with mock.patch.object( - auto, "_pygame_available", return_value=False - ), mock.patch.object(auto.sys, "platform", "linux"): - self.assertIn("sdl2", auto._auto_backends()) - - def test_auto_backends_skips_sdl2_on_android(self): - from unittest import mock - - from multimer import auto - - with mock.patch.object( - auto, "_pygame_available", return_value=False - ), mock.patch.object(auto.sys, "platform", "android"): - self.assertNotIn("sdl2", auto._auto_backends()) - - def test_async_backend_selects_awaitable_sleep(self): - from multimer import auto - - provider = auto._load_backend("async") - self.assertIs(provider.Timer, AsyncTimer) - self.assertTrue(provider.is_async) - self.assertFalse(provider.uses_interrupts) - coro = provider.sleep_ms(0) - self.addCleanup(coro.close) - self.assertTrue(hasattr(coro, "send")) - - def test_unknown_backend_raises_value_error(self): - from multimer import auto + def test_freq_overrides_period(self): + tim = Timer(-1, freq=200, period=999, callback=lambda t: None) + self.addCleanup(tim.deinit) + self.assertEqual(5, tim.period) + def test_invalid_arguments(self): with self.assertRaises(ValueError): - auto._load_backend("no_such_backend") - - def test_unavailable_backend_raises_import_error(self): - # ``machine.Timer`` is absent on CPython desktop; the selection must not - # fall back silently when a caller asks for a specific backend. - try: - from machine import Timer as _MachineTimer # noqa: F401 - except ImportError: - pass - else: - self.skipTest("machine.Timer is available on this host") - with self.assertRaises(ImportError): - from multimer import machine # noqa: F401 + Timer(-1, mode=7, period=10, callback=lambda t: None) + with self.assertRaises(ValueError): + Timer(-1, period=0, callback=lambda t: None) + with self.assertRaises(ValueError): + Timer(-1, period=10, callback=42) def test_context_manager_deinits(self): - hits = [] - from multimer import polling - - with polling.Timer(-1) as t: - t.init(period=20, callback=lambda _t: hits.append(1)) - for _ in range(8): - polling.sleep_ms(10) - self.assertGreaterEqual(len(hits), 1) - # After exit the timer must be disarmed. - n = len(hits) - polling.sleep_ms(50) - self.assertEqual(len(hits), n) - - def test_provider_constructor_matches_machine_timer_initialization(self): - from multimer import polling - - hits = [] - timer = polling.Timer( - -1, - mode=polling.Timer.ONE_SHOT, - period=10, - callback=lambda _timer: hits.append(1), - ) - try: - polling.sleep_ms(30) - finally: - timer.deinit() - self.assertEqual(hits, [1]) + with Timer(-1, period=50, callback=lambda t: None) as tim: + self.assertTrue(tim.running) + self.assertFalse(tim.running) + self.assertNotIn(tim, multimer.timers()) + + def test_repr_shows_state(self): + tim = multimer.every(10, lambda t: None, name="probe") + self.addCleanup(tim.deinit) + text = repr(tim) + self.assertIn("name='probe'", text) + self.assertIn("period=10", text) + self.assertIn("PERIODIC", text) + + def test_report_names_source_and_timers(self): + tim = multimer.every(10, lambda t: None, name="reported") + self.addCleanup(tim.deinit) + buf = io.StringIO() + multimer.report(buf) + text = buf.getvalue() + self.assertIn("multimer on cpython", text) + self.assertIn("source=", text) + self.assertIn("name='reported'", text) class TestTicks(unittest.TestCase): def test_ticks_ms_in_range(self): t = ticks_ms() - self.assertIsInstance(t, int) self.assertGreaterEqual(t, 0) self.assertLessEqual(t, _TICKS_MAX) - def test_monotonic_advances(self): - start = monotonic() - self.assertIsInstance(start, (int, float)) - sleep_ms(20) - self.assertGreaterEqual(monotonic(), start) + def test_ticks_us_advances(self): + a = multimer.ticks_us() + time.sleep(0.002) + self.assertGreater(ticks_diff(multimer.ticks_us(), a), 1000) def test_ticks_add_wrap(self): self.assertEqual(ticks_add(_TICKS_MAX, 1), 0) - def test_ticks_add_rejects_ambiguous_intervals(self): - with self.assertRaises(OverflowError): - ticks_add(0, _TICKS_HALFPERIOD) - with self.assertRaises(OverflowError): - ticks_add(0, -_TICKS_HALFPERIOD) - - def test_host_native_tick_period_is_normalized(self): - from unittest import mock - - native_add = mock.Mock(return_value=_TICKS_PERIOD) - native_diff = mock.Mock(return_value=1) - with mock.patch.object( - time, "ticks_ms", return_value=_TICKS_PERIOD + 7, create=True - ), mock.patch.object( - time, "ticks_add", native_add, create=True - ), mock.patch.object( - time, "ticks_diff", native_diff, create=True - ): - portable = runpy.run_path(os.path.join(_env.MULTIMER_DIR, "_ticks.py")) - - self.assertEqual(portable["ticks_ms"](), 7) - self.assertEqual(portable["ticks_add"](_TICKS_MAX, 1), 0) - self.assertEqual(portable["ticks_diff"](0, _TICKS_MAX), 1) - native_add.assert_not_called() - native_diff.assert_not_called() - def test_ticks_diff_wrap(self): - later = ticks_add(_TICKS_MAX, 10) - self.assertEqual(ticks_diff(later, _TICKS_MAX), 10) + self.assertEqual(ticks_diff(0, _TICKS_MAX), 1) + self.assertEqual(ticks_diff(_TICKS_MAX, 0), -1) def test_ticks_less(self): - self.assertTrue(ticks_less(100, 200)) + self.assertTrue(ticks_less(1, 2)) + self.assertFalse(ticks_less(2, 1)) - def test_sleep_ms_advances_time(self): - start = ticks_ms() - sleep_ms(50) - self.assertGreaterEqual(ticks_diff(ticks_ms(), start), 40) + def test_deadlines_survive_wraparound(self): + # A timer whose deadline wraps past 2**29 must still be seen as due. + from multimer import _dispatch + tim = Timer(-1) + tim.init(period=10, callback=lambda t: None) + self.addCleanup(tim.deinit) + tim._due = ticks_add(ticks_ms(), -5) # already due, wherever "now" is + self.assertEqual(0, _dispatch.next_delay_ms()) -class TestTimerSemantics(unittest.TestCase): - def test_periodic_fires(self): - hits = [] - main_thread = threading.get_ident() - callback_threads = [] - - def cb(t): - hits.append(t) - callback_threads.append(threading.get_ident()) - - t = Timer(-1) - t.init(period=50, callback=cb) - for _ in range(35): - sleep_ms(10) - t.deinit() - self.assertGreaterEqual(len(hits), 2) - self.assertIs(hits[0], t) - self.assertTrue(callback_threads) - self.assertEqual(set(callback_threads), {main_thread}) - - def test_one_shot_fires_once(self): - hits = [] - main_thread = threading.get_ident() - callback_threads = [] - def cb(t): - hits.append(t) - callback_threads.append(threading.get_ident()) +class TestDelivery(unittest.TestCase): + def tearDown(self): + multimer.stop_all() - t = Timer(-1) - t.init(mode=Timer.ONE_SHOT, period=50, callback=cb) - for _ in range(25): - sleep_ms(10) - self.assertEqual(len(hits), 1) - self.assertEqual(callback_threads, [main_thread]) - - def test_freq_overrides_period(self): + def test_periodic_fires_on_schedule(self): hits = [] - - t = Timer(-1) - t.init(freq=20, period=1, callback=lambda _t: hits.append(1)) - for _ in range(25): - sleep_ms(10) - t.deinit() - self.assertGreaterEqual(len(hits), 2) - self.assertLessEqual(len(hits), 12) - - def test_soft_coalesce_under_threading(self): - """``hard=False`` must go through ``_deliver`` (coalesce), not raw invoke.""" - try: - from multimer import threading as thread_timer - except ImportError: - self.skipTest("threading backend unavailable") + tim = multimer.every(10, lambda t: hits.append(ticks_ms())) + multimer.sleep_ms(205) + tim.deinit() + self.assertGreaterEqual(len(hits), 18, hits) + self.assertLessEqual(len(hits), 21, hits) + gaps = [ticks_diff(b, a) for a, b in zip(hits, hits[1:])] + self.assertLessEqual(max(gaps), 14, gaps) + self.assertGreaterEqual(min(gaps), 6, gaps) + + def test_one_shot_fires_once_and_retires(self): + hits = [] + tim = multimer.after(20, hits.append) + multimer.sleep_ms(80) + self.assertEqual(1, len(hits)) + self.assertFalse(tim.running) + self.assertIsNone(tim.due_in) + self.assertEqual(1, tim.fired) + self.assertNotIn(tim, multimer.timers()) + + def test_callbacks_run_on_the_main_thread(self): + idents = [] + tim = multimer.every(5, lambda t: idents.append(threading.get_ident())) + multimer.sleep_ms(40) + tim.deinit() + self.assertTrue(idents) + self.assertEqual({threading.main_thread().ident}, set(idents)) + + def test_self_deinit_from_callback(self): hits = [] - def cb(_t): + def once(t): hits.append(1) - thread_timer.sleep_ms(40) - - t = thread_timer.Timer(-1) - t.init(period=10, callback=cb, hard=False) - for _ in range(20): - thread_timer.sleep_ms(10) - t.deinit() - # Without coalesce a 10 ms period over ~200 ms would enqueue many more. - self.assertGreaterEqual(len(hits), 1) - self.assertLessEqual(len(hits), 8) - - -class TestSelfDeinit(unittest.TestCase): - """``deinit()`` from inside a timer's own callback must return, not deadlock. - - ``_deliver()`` holds ``_busy`` for the duration of the callback, and - ``deinit()`` -> ``_wait_idle()`` used to spin on it, so the delivering thread - waited on itself forever. ``machine.Timer`` permits self-deinit from an ISR, - so the software providers must too. - - Delivery is forced onto the ``threading`` provider's worker thread so a - regression surfaces as a failed deadline rather than hanging the suite. - """ - - def _self_deinit(self, hard): - try: - from multimer import threading as thread_timer - except ImportError: - self.skipTest("threading backend unavailable") - done = [] - t = thread_timer.Timer(-1) - - def cb(tim): - tim.deinit() - done.append(1) - - t.init(period=10, callback=cb, hard=hard) - deadline = time.monotonic() + 2.0 - while not done and time.monotonic() < deadline: - thread_timer.sleep_ms(10) - self.assertTrue(done, "deinit() from inside the timer's own callback did not return") - - def test_self_deinit_hard(self): - self._self_deinit(True) - - def test_self_deinit_soft(self): - self._self_deinit(False) - - def test_wait_idle_returns_while_delivering(self): - """The reentrancy marker, unit-tested without a live timer.""" - from multimer._core import _TimerCore - - core = _TimerCore.__new__(_TimerCore) - core._busy = True - core._delivering = True - core._wait_idle() # must return immediately - - -class TestMpAsyncioShim(unittest.TestCase): - """``_mpasyncio`` must match the interpreter it borrows ``_asyncio`` from. - - Awaitables: CircuitPython requires ``__await__`` on the operand where - MicroPython accepts any iterator, and the shim is shared. - - Ticks: due-times land in ``_asyncio.TaskQueue``, a C pairing heap that - orders them in the interpreter's own ticks domain. - """ - - def setUp(self): - try: - from multimer import _mpasyncio - except ImportError: - self.skipTest("_mpasyncio unavailable (build ships a real asyncio)") - self.mod = _mpasyncio - - def test_sleep_is_awaitable(self): - self.assertTrue(hasattr(self.mod.sleep(0), "__await__")) - self.mod._sleep_ms_sgen.state = None - self.assertTrue(hasattr(self.mod.sleep_ms(0), "__await__")) - self.mod._sleep_ms_sgen.state = None - - def test_event_wait_is_awaitable(self): - self.assertTrue(hasattr(self.mod.Event().wait(), "__await__")) - - def test_ticks_domain_matches_the_task_queue(self): - """multimer's ticks_ms masks to 29 bits; time.ticks_ms is 30-bit. - - Handing the C task queue the masked value made every key sort half a - period away, so tasks were never popped and an AsyncTimer armed under - this shim never fired. - """ - native = getattr(time, "ticks_ms", None) - if native is None: - self.skipTest("interpreter has no time.ticks_ms") - self.assertIs(native, self.mod.ticks) - - -class TestAsyncTimer(unittest.TestCase): - def test_requires_running_loop(self): - t = AsyncTimer(-1) - with self.assertRaises(RuntimeError): - t.init(period=20, callback=lambda _t: None) - - def test_periodic_under_asyncio(self): - import asyncio as std_asyncio - - hits = [] - main_thread = threading.get_ident() - callback_threads = [] - - async def main(): - t = AsyncTimer(-1) - t.init( - period=20, - callback=lambda tim: ( - hits.append(tim), - callback_threads.append(threading.get_ident()), - ), - ) - await std_asyncio.sleep(0.15) t.deinit() - std_asyncio.run(main()) - self.assertGreaterEqual(len(hits), 2) - self.assertEqual(set(callback_threads), {main_thread}) + multimer.every(5, once) + multimer.sleep_ms(40) + self.assertEqual(1, len(hits)) - -class TestLoopRunning(unittest.TestCase): - def test_false_outside_a_loop(self): - self.assertFalse(multimer.loop_running()) - - def test_true_inside_a_loop(self): - from multimer import asyncio - - async def main(): - return multimer.loop_running() - - self.assertTrue(asyncio.run(main())) - - def test_ignores_get_event_loop(self): - """``get_event_loop`` returns a loop even when none runs, so it must not be used. - - A backend offering only ``get_event_loop`` has to report "no loop" rather - than trusting it — the case that made appdev defer async timers forever - on MicroPython. - """ - from multimer import _asyncio_loader - - class OnlyGetEventLoop: - def get_event_loop(self): - return "a loop that is not running" - - saved = _asyncio_loader._asyncio_mod - _asyncio_loader._asyncio_mod = OnlyGetEventLoop() + def test_raising_callback_keeps_its_schedule_and_prints_once(self): + err = io.StringIO() + real = sys.stderr + sys.stderr = err try: - self.assertFalse(_asyncio_loader.loop_running()) - finally: - _asyncio_loader._asyncio_mod = saved - - def test_prefers_current_task_over_get_running_loop(self): - """CircuitPython's ``get_running_loop()`` succeeds with no loop running.""" - from multimer import _asyncio_loader - - class LyingGetRunningLoop: - def current_task(self): - return None - - def get_running_loop(self): - return "a loop that is not running" - - saved = _asyncio_loader._asyncio_mod - _asyncio_loader._asyncio_mod = LyingGetRunningLoop() - try: - self.assertFalse(_asyncio_loader.loop_running()) + tim = multimer.every(5, lambda t: 1 / 0) + multimer.sleep_ms(60) + tim.deinit() finally: - _asyncio_loader._asyncio_mod = saved - - -class TestSchedule(unittest.TestCase): - def test_schedule_main_thread(self): + sys.stderr = real + self.assertGreaterEqual(tim.fired, 5) + self.assertIsInstance(tim.error, ZeroDivisionError) + self.assertEqual(1, err.getvalue().count("ZeroDivisionError")) + + def test_schedule_runs_at_the_next_safe_point(self): + # On a bytecode host the next safe point is the very next bytecode, + # so the work may already be done by the next line; what is promised + # is that it never runs inside a hold, and has run once pump() returns. + seen = [] + with multimer.hold(): + multimer.schedule(seen.append, "x") + t0 = ticks_ms() + while ticks_diff(ticks_ms(), t0) < 20: + pass + self.assertEqual([], seen, "a hold masks scheduled work too") + multimer.pump() + self.assertEqual(["x"], seen) + + def test_schedule_from_another_thread_lands_on_main(self): seen = [] - multimer.schedule(seen.append, 42) - self.assertEqual(seen, [42]) + th = threading.Thread(target=multimer.schedule, args=(lambda a: seen.append(threading.get_ident()), None)) + th.start() + th.join() + _wait(lambda: seen) + self.assertEqual([threading.main_thread().ident], seen) + + def test_run_until(self): + count = [] + tim = multimer.every(5, lambda t: count.append(1)) + multimer.run_until(lambda: len(count) >= 3) + tim.deinit() + self.assertGreaterEqual(len(count), 3) + + +class TestRulesThatKeepOldBugsDead(unittest.TestCase): + """Each of these cost a night before the redesign. Each is shown to fail + without the rule: the assertions are on behaviour the dispatcher + enforces, and the numbers would come out the other way with today's + per-provider delivery (the baselines in proposals/timing record them).""" + + def tearDown(self): + multimer.stop_all() + + def test_hold_masks_delivery_and_flushes_at_exit(self): + # The audio-pump class: a scheduled tick re-entering Python inside a + # critical section. Inside hold() nothing is delivered; what came due + # is delivered once at exit, not in a burst. + hits = [] + tim = multimer.every(5, lambda t: hits.append(ticks_ms())) + multimer.sleep_ms(12) + before = len(hits) + with multimer.hold(): + t0 = ticks_ms() + while ticks_diff(ticks_ms(), t0) < 60: + pass + inside = len(hits) + after = len(hits) + tim.deinit() + self.assertEqual(before, inside, "delivered inside a hold") + self.assertEqual(inside + 1, after, "exactly one catch-up delivery at exit") + + def test_a_callback_is_never_interrupted_by_another(self): + order = [] + + def slow(t): + order.append("slow-in") + t0 = ticks_ms() + while ticks_diff(ticks_ms(), t0) < 30: + pass + order.append("slow-out") + + def fast(t): + order.append("fast") + + a = multimer.every(50, slow) + b = multimer.every(5, fast) + multimer.sleep_ms(120) + a.deinit() + b.deinit() + text = " ".join(order) + self.assertIn("slow-in slow-out", text, text) + + def test_overrun_lowers_the_rate_instead_of_taking_the_thread(self): + # lvgl-bindings#15: a 30 ms pass on a 10 ms timer must not run + # back-to-back. Between two passes the main line must get at least as + # long as the pass took. + starts = [] + ends = [] + + def pass_(t): + starts.append(ticks_ms()) + t0 = ticks_ms() + while ticks_diff(ticks_ms(), t0) < 30: + pass + ends.append(ticks_ms()) + + tim = multimer.every(10, pass_) + multimer.sleep_ms(250) + tim.deinit() + self.assertGreaterEqual(len(starts), 3, starts) + idle = [ticks_diff(s, e) for e, s in zip(ends, starts[1:])] + self.assertGreaterEqual(min(idle), 28, idle) + self.assertGreater(tim.missed, 0) + + def test_yield_is_capped(self): + # lvgl-bindings#19: a 150 ms pass holds for the cap (100 ms by + # default), not for another 150 ms of idle. + starts = [] + ends = [] + + def pass_(t): + starts.append(ticks_ms()) + t0 = ticks_ms() + while ticks_diff(ticks_ms(), t0) < 150: + pass + ends.append(ticks_ms()) + + tim = multimer.every(10, pass_) + multimer.sleep_ms(600) + tim.deinit() + idle = [ticks_diff(s, e) for e, s in zip(ends, starts[1:])] + self.assertTrue(idle, starts) + self.assertGreaterEqual(min(idle), 98, idle) + self.assertLessEqual(max(idle), 125, idle) + + def test_yield_cap_zero_keeps_only_the_grid(self): + starts = [] + + def pass_(t): + starts.append(ticks_ms()) + t0 = ticks_ms() + while ticks_diff(ticks_ms(), t0) < 25: + pass + + tim = multimer.every(10, pass_) + tim.yield_cap = 0 + multimer.sleep_ms(200) + tim.deinit() + gaps = [ticks_diff(b, a) for a, b in zip(starts, starts[1:])] + self.assertLessEqual(max(gaps), 34, gaps) + + def test_no_catch_up_burst_after_a_stall(self): + hits = [] + tim = multimer.every(5, lambda t: hits.append(ticks_ms())) + multimer.sleep_ms(12) + with multimer.hold(): + time.sleep(0.2) + multimer.sleep_ms(30) + tim.deinit() + gaps = [ticks_diff(b, a) for a, b in zip(hits, hits[1:])] + self.assertFalse([g for g in gaps if g < 2], gaps) + self.assertGreaterEqual(tim.missed, 30) + + def test_deadlines_are_absolute(self): + # Delivery latency must not drift the schedule: after N periods the + # timer is still on the grid it started on. + hits = [] + tim = multimer.every(10, lambda t: hits.append(ticks_ms())) + multimer.sleep_ms(505) + tim.deinit() + self.assertGreaterEqual(len(hits), 48, len(hits)) + span = ticks_diff(hits[-1], hits[0]) + self.assertAlmostEqual(span / (len(hits) - 1), 10, delta=0.3) + + +class TestWakeSources(unittest.TestCase): + """Each source in its own interpreter.""" + + CODE = r""" +import sys, threading, time, multimer +from multimer import ticks_ms, ticks_diff +hits = [] +idents = set() +def cb(t): + hits.append(ticks_ms()); idents.add(threading.get_ident()) +tim = multimer.every(10, cb) +multimer.sleep_ms(200) +idle = len(hits) +hits.clear() +t0 = ticks_ms() +while ticks_diff(ticks_ms(), t0) < 200: + pass +busy = len(hits) +hits.clear() +time.sleep(0.2) # a blocking sleep the source may or may not wake +blocked = len(hits) +multimer.sleep_ms(30) # the burst check: at most one catch-up after the stall +catchup = len(hits) - blocked +tim.deinit() +print(multimer.info()["source"], idle, busy, blocked, catchup, idents == {threading.main_thread().ident}) +""" + + def _run(self, source): + p = _child(self.CODE, source=source) + self.assertEqual(0, p.returncode, p.stderr) + name, idle, busy, blocked, catchup, on_main = p.stdout.split() + return name, int(idle), int(busy), int(blocked), int(catchup), on_main == "True" + + def test_signal_source_delivers_everywhere(self): + if sys.platform not in ("linux", "darwin"): + self.skipTest("POSIX only") + name, idle, busy, blocked, catchup, on_main = self._run("signal") + self.assertEqual("signal", name) + self.assertGreaterEqual(idle, 18) + self.assertGreaterEqual(busy, 18, "bytecode delivery while the main thread computes") + self.assertGreaterEqual(blocked, 18, "a signal wakes time.sleep too") + self.assertTrue(on_main) + + def test_pending_source_delivers_between_bytecodes_on_the_main_thread(self): + # The Android class (SDL's timer thread refused by EGL) and the + # Windows class (APCs needing an alertable wait) both die here: the + # worker only keeps time, the callback lands on the main thread. + name, idle, busy, blocked, catchup, on_main = self._run("pending") + self.assertEqual("pending", name) + self.assertGreaterEqual(idle, 18) + self.assertGreaterEqual(busy, 15, "delivery between bytecodes, GIL switch interval permitting") + self.assertLessEqual(blocked, 1, "a pending call cannot wake time.sleep; documented") + self.assertLessEqual(catchup, 3, "no burst after the stall: one catch-up at most, then the grid") + self.assertTrue(on_main) + + def test_none_source_delivers_only_at_idle_points(self): + name, idle, busy, blocked, catchup, on_main = self._run("none") + self.assertEqual("none", name) + self.assertGreaterEqual(idle, 18, "sleep_ms serves the heap itself") + self.assertEqual(0, busy, "the planted fault: nothing wakes a busy loop") + self.assertEqual(0, blocked) + + def test_asyncio_source_rides_a_running_loop(self): + code = r""" +import asyncio, multimer +hits = [] +async def main(): + tim = multimer.every(10, lambda t: hits.append(1)) + await asyncio.sleep(0.2) + tim.deinit() + print(multimer.info()["source"], len(hits)) +asyncio.run(main()) +""" + p = _child(code, source="asyncio") + self.assertEqual(0, p.returncode, p.stderr) + name, n = p.stdout.split() + self.assertEqual("asyncio", name) + self.assertGreaterEqual(int(n), 17) + + def test_unknown_source_fails_loud(self): + p = _child("import multimer; multimer.every(10, lambda t: None); print(multimer.info()['source'], multimer.info().get('source_error'))", source="bogus") + self.assertIn("none", p.stdout) + self.assertIn("unknown multimer source", p.stdout) if __name__ == "__main__": diff --git a/tests/test_standalone.py b/tests/test_standalone.py index d7b12e47..a90280de 100644 --- a/tests/test_standalone.py +++ b/tests/test_standalone.py @@ -29,14 +29,14 @@ import multimer from multimer import ( - AsyncTimer, + Timer, schedule, + sleep_ms, ticks_add, ticks_diff, ticks_less, ticks_ms, ) - from multimer import auto as timer forbidden = [m for m in {siblings!r} if m in sys.modules] assert not forbidden, "multimer pulled in sibling modules: %r" % forbidden @@ -44,17 +44,18 @@ assert ticks_ms() >= 0 seen = [] schedule(lambda x: seen.append(x), 1) + multimer.pump() assert seen == [1], seen hits = [] - t = timer.Timer(-1) + t = Timer(-1) t.init(period=50, callback=lambda tim: hits.append(tim)) deadline = time.monotonic() + 0.35 while time.monotonic() < deadline: - timer.sleep_ms(10) + sleep_ms(10) t.deinit() assert hits, "standalone timer never fired" - assert AsyncTimer is not None, "AsyncTimer should be available on CPython" + assert multimer.info()["source"] is not None, "a wake source should have been chosen" print("STANDALONE_OK") """ diff --git a/tests/test_wasm_backends.py b/tests/test_wasm_backends.py index 36e16735..1049a069 100644 --- a/tests/test_wasm_backends.py +++ b/tests/test_wasm_backends.py @@ -86,7 +86,7 @@ def setUp(self): self.bridge = FakeBridge() self.modules_patch = mock.patch.dict(sys.modules, {"_wasm_bridge": self.bridge}) self.modules_patch.start() - for name in ("displaydev.wasmdisplay", "audiodev.wasm_audio", "multimer.wasm"): + for name in ("displaydev.wasmdisplay", "audiodev.wasm_audio", "multimer._src_wasm"): sys.modules.pop(name, None) def tearDown(self): @@ -227,50 +227,54 @@ def test_audio_permissions_fail_clearly(self): with self.assertRaisesRegex(RuntimeError, "Enable Microphone"): WasmPCMInput().open() - def test_timer_one_shot_rearm_and_self_deinit(self): - from multimer.wasm import Timer, pump - - fired = [] - one = Timer( - -1, - period=10, - mode=Timer.ONE_SHOT, - callback=lambda timer: fired.append(timer.id), - ) - self.bridge.timer_fired.append(one.id) - pump() - self.assertEqual(fired, [one.id]) - self.assertNotIn(one.id, self.bridge.timers) - - periodic = Timer(-1) - periodic.init( - period=20, mode=Timer.PERIODIC, callback=lambda timer: timer.deinit() - ) - self.bridge.timer_fired.append(periodic.id) - pump() - self.assertNotIn(periodic.id, self.bridge.timers) - periodic.init(period=30, callback=lambda _timer: None, hard=False) - self.assertEqual(self.bridge.timers[periodic.id][:2], (30, True)) - self.assertTrue(callable(self.bridge.timers[periodic.id][2])) - periodic.deinit() + def test_wasm_source_arms_one_browser_timer_and_polls_queued_firings(self): + # A fresh import against this test's bridge: ``from multimer import + # _src_wasm`` would hand back the package attribute an earlier test + # left, bound to that test's bridge. + src = importlib.import_module("multimer._src_wasm") + if getattr(src, "_wasm_bridge", None) is not self.bridge: + src = importlib.reload(src) + + woken = [] + src.start(lambda: woken.append(1)) + try: + src.arm(10) + self.assertEqual(self.bridge.timers[src._ID][:2], (10, False)) + self.assertTrue(callable(self.bridge.timers[src._ID][2])) + # The bridge calls the callback itself once the VM is idle... + self.bridge.timers[src._ID][2]() + self.assertEqual(woken, [1]) + # ...and queues a firing while Python is inside an Asyncify sleep. + src.arm(20) + self.bridge.timer_fired.append(src._ID) + src.sleep_ms(1) + self.assertEqual(woken, [1, 1]) + src.cancel() + self.assertNotIn(src._ID, self.bridge.timers) + finally: + src.stop() def test_automatic_selectors_prefer_builtin_bridge(self): import audiodev.auto import displaydev.auto - import multimer.auto + from multimer import _dispatch self.assertEqual(importlib.reload(displaydev.auto).host_kind(), "wasm") audio_auto = importlib.reload(audiodev.auto) with mock.patch.object(audio_auto, "_is_micropython", return_value=True): self.assertEqual(audio_auto.select_backend(), "wasm_audio") - self.assertEqual(importlib.reload(multimer.auto).name, "wasm") + src = _dispatch._select_source() + try: + self.assertEqual(src.name, "wasm") + finally: + src.stop() def test_python_backends_do_not_import_browser_proxies(self): root = Path(__file__).parents[1] / "lib" for relative in ( "displaydev/wasmdisplay.py", "audiodev/wasm_audio.py", - "multimer/wasm.py", + "multimer/_src_wasm.py", ): tree = ast.parse((root / relative).read_text("utf-8")) names = []