You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(frozen): version metadata + mount-time Bonjour prewarm (#55)
Two bugs that surfaced only in the curl-installed v1.0.9 frozen
binary (uv run diting was fine).
1. `--version` printed `diting 0+unknown` and the TUI header
rendered `diting v0+unknown`. v1.0.9 shipped without
`--copy-metadata diting` in the PyInstaller invocation, so
`importlib.metadata.version("diting")` raised
`PackageNotFoundError` in the frozen build and `__version__`
fell back to the sentinel. Fix: add `--copy-metadata diting`
to `scripts/build_frozen.py`. Regression test in
`tests/test_helper.py::test_frozen_build_copies_diting_metadata`.
2. Pressing `n` to switch to Bonjour hung >1.5 s. The
"first wifi → BLE" prewarm trigger landed in 1.0.x worked
on the source build because `.py` file reads release the GIL
during the actual `open()` syscall. PyInstaller's
`PyiFrozenImporter` decompresses each module from a PYZ
archive while holding the GIL throughout, so
`asyncio.to_thread(_import_bonjour_poller)` doesn't actually
overlap with the event loop — the import is synchronous from
the loop's perspective. Fix: move the prewarm trigger from
`action_toggle_view` to `App.on_mount` so it has the entire
wifi-view dwell time to amortise. The existing trigger stays
as an idempotent safety net.
Tests / spec:
- `tests/test_tui_smoke.py`: renamed `test_app_constructs_bonjour_panel_lazily`
to `test_bonjour_prewarms_at_mount`; replaced the
prewarm-on-wifi-to-BLE test with one that asserts mount-time
prewarm makes view switches idempotent.
- `_inject_bonjour_devices` now sets `app._paused = True` so the
mount-time consumer task's first empty yield does not race with
injected test devices.
- OpenSpec: `prewarm-bonjour-at-mount` modifies the mdns-scanning
capability's prewarm trigger from "first wifi → BLE" to
"TUI mount". Validates strict.
Version bumped to 1.0.10 with EN + ZH CHANGELOG entries.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
### Requirement: `zeroconf` dependency SHALL be lazy-imported at the module boundary and pre-warmed at TUI mount
4
+
`from zeroconf import ...` SHALL appear ONLY inside `src/diting/mdns.py` and SHALL be top-level inside that module (not function-local). `src/diting/tui.py` SHALL NOT `import diting.mdns` at module load.
5
+
6
+
The TUI SHALL trigger the first import of `diting.mdns` (and the construction of a `BonjourPoller`) at TUI mount — `App.on_mount` SHALL call `_ensure_mdns_poller()` after scheduling the other pollers. The pre-warm SHALL run on a worker (`run_worker` + `asyncio.to_thread` for the slow stages) so the visible Wi-Fi view renders immediately and the user's first ~5 s of reading the wifi panel amortises the zeroconf import + multicast socket setup. The `action_toggle_view` call into `_ensure_mdns_poller()` SHALL remain as an idempotent safety net but is a no-op once the mount-time prewarm has fired.
7
+
8
+
**Why mount-time instead of "first wifi → BLE"**: the PyInstaller-frozen binary's `PyiFrozenImporter` decompresses each imported module from a PYZ archive while holding the GIL throughout, so `asyncio.to_thread` cannot overlap the import with the event loop. The previous "first leaving Wi-Fi" trigger gave the frozen build only the ~2 s of BLE-view reading time to absorb a 1.5+ s import; with mount-time prewarm, the entire wifi-view dwell time is available. The source `uv run` build benefits too — the import overlaps with TUI initial paint instead of with a view switch.
9
+
10
+
#### Scenario: TUI mount kicks off the Bonjour prewarm
11
+
-**WHEN** the user launches the TUI
12
+
-**THEN**`App.on_mount` schedules a worker that imports `diting.mdns`, constructs a `BonjourPoller`, and begins the consumer task — all off the asyncio event loop
13
+
-**AND** the Wi-Fi panel renders immediately, with no perceptible pause attributable to Bonjour startup
14
+
15
+
#### Scenario: User cycles wifi → BLE → mDNS for the first time
16
+
-**WHEN** the user presses `n` once (wifi → BLE)
17
+
-**THEN** the BLE view appears immediately; the mount-time prewarm is either complete or in flight
18
+
-**WHEN** the user presses `n` a second time (BLE → mDNS)
19
+
-**THEN** the mDNS panel is shown immediately (the poller is ready since it has had the entire wifi-view dwell time to initialise)
20
+
-**AND** subsequent `n` cycles back to mDNS reuse the same poller (no re-instantiate)
21
+
22
+
#### Scenario: User never leaves Wi-Fi view
23
+
-**WHEN** the user runs `diting` and never presses `n`
24
+
-**THEN** zeroconf is still imported at mount (background worker), but no user-visible cost is incurred — the work happens concurrently with the user reading the wifi view
-[x] 1.1 In `src/diting/tui.py``App.on_mount`, add `self._ensure_mdns_poller()` after the LatencyPoller worker block. Keep the existing `action_toggle_view` call as an idempotent no-op fallback.
4
+
5
+
## 2. PyInstaller version metadata (related fix that ships in same release)
6
+
7
+
-[x] 2.1 Add `--copy-metadata diting` to the PyInstaller invocation in `scripts/build_frozen.py` so `importlib.metadata.version("diting")` resolves in the frozen binary (v1.0.9 shipped with this missing, producing `diting v0+unknown` in the TUI title).
8
+
-[x] 2.2 Add a regression test in `tests/test_helper.py` asserting `scripts/build_frozen.py` still contains the `--copy-metadata diting` flag.
9
+
10
+
## 3. Tests
11
+
12
+
-[x] 3.1 Rename `test_app_constructs_bonjour_panel_lazily` → `test_bonjour_prewarms_at_mount` and assert `_mdns_starting` or `_mdns_poller` is set right after mount.
13
+
-[x] 3.2 Rewrite `test_bonjour_prewarms_on_first_wifi_to_ble_switch` → `test_bonjour_view_switch_is_idempotent_after_mount_prewarm` to assert pressing `n` after the mount-time prewarm does not replace the poller.
14
+
-[x] 3.3 Update `_inject_bonjour_devices` in `tests/test_tui_smoke.py` to set `app._paused = True` so the mount-time consumer task's first empty yield does not overwrite injected devices.
15
+
16
+
## 4. Gates
17
+
18
+
-[x] 4.1 `uv run pytest` passes.
19
+
-[x] 4.2 `uv run python scripts/tui_snapshot.py --mode regression` passes.
0 commit comments