Skip to content

Commit e9e1b1c

Browse files
chenchaoyiclaude
andauthored
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>
1 parent 672e898 commit e9e1b1c

12 files changed

Lines changed: 250 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,41 @@ the project follows [Semantic Versioning](https://semver.org/) where
99
practical. The leading `v0.x` line is allowed to break minor
1010
behaviours between releases.
1111

12+
## [1.0.10] — 2026-05-14
13+
14+
Two fixes that surface only in the curl-installed frozen binary
15+
(not in `uv run diting`).
16+
17+
### Fixed
18+
- **Frozen binary's `--version` and TUI title now report the real
19+
version.** v1.0.9 shipped without `--copy-metadata diting` in the
20+
PyInstaller invocation, so `importlib.metadata.version("diting")`
21+
raised `PackageNotFoundError` in the frozen build and
22+
`__version__` fell back to `"0+unknown"`. Result: `diting
23+
--version` printed `diting 0+unknown` and the TUI header rendered
24+
`diting v0+unknown`. Adding `--copy-metadata diting` to
25+
`scripts/build_frozen.py` packs the dist-info into the frozen
26+
archive. Regression test in `tests/test_helper.py` guards against
27+
the flag being removed again.
28+
- **Bonjour prewarm now starts at TUI mount, not on first
29+
wifi → BLE.** The "first wifi → BLE" trigger landed in
30+
1.0.x worked on the source build because `.py` file reads release
31+
the GIL during the actual `open()` syscall, so the prewarm
32+
worker overlapped with the BLE view's reading time. PyInstaller's
33+
`PyiFrozenImporter` decompresses modules from a PYZ archive
34+
inside pure-Python code that holds the GIL throughout — so
35+
`asyncio.to_thread` doesn't help, and users on v1.0.9 saw the
36+
second `n` press (BLE → mDNS) hang for >1.5 s. Moving the
37+
trigger to `App.on_mount` gives the prewarm the entire wifi-view
38+
dwell time to amortise, which is plenty.
39+
40+
### Spec / breaking-ish note
41+
The `mdns-scanning` capability's "user who only uses Wi-Fi view
42+
never imports zeroconf" guarantee no longer holds — every TUI
43+
session now imports zeroconf at mount. The cost runs in a worker
44+
so there's no user-visible slowdown; the change is documented in
45+
the `prewarm-bonjour-at-mount` OpenSpec change.
46+
1247
## [1.0.9] — 2026-05-14
1348

1449
Small, useful: a way to tell what version of diting you're running.

docs/zh/CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,35 @@
88
[Semantic Versioning](https://semver.org/)`v0.x` 阶段允许破坏性的次要
99
行为变更。
1010

11+
## [1.0.10] — 2026-05-14
12+
13+
两个只在 curl 装的冻结 binary 里出现的 bug(`uv run diting` 都没有)。
14+
15+
### 修复
16+
- **冻结 binary 的 `--version` 和 TUI 标题现在能正确显示版本号了。**
17+
v1.0.9 漏了 PyInstaller 的 `--copy-metadata diting`,导致冻结
18+
binary 里 `importlib.metadata.version("diting")`
19+
`PackageNotFoundError``__version__` 兜底到 `"0+unknown"`
20+
结果:`diting --version` 打印 `diting 0+unknown`,TUI 顶部也是
21+
`diting v0+unknown`。在 `scripts/build_frozen.py` 里加上
22+
`--copy-metadata diting` 就把 dist-info 打进了冻结归档。
23+
`tests/test_helper.py` 里加了回归测试,避免再被移掉。
24+
- **Bonjour 预热改成在 TUI mount 时启动,不再等到第一次按
25+
wifi → BLE。** 1.0.x 时落的「第一次离开 Wi-Fi 视图触发预热」对
26+
源码安装路径有效,因为 `.py` 文件读 IO 时 CPython 会释放 GIL,
27+
预热 worker 能和 BLE 视图的浏览时间重叠。但 PyInstaller 的
28+
`PyiFrozenImporter` 从 PYZ 归档解压模块时全程持 GIL,
29+
`asyncio.to_thread` 并帮不上忙——所以 v1.0.9 用户在按第二次
30+
`n`(BLE → mDNS)时会卡 1.5 s 以上。把触发点改到
31+
`App.on_mount`,预热就拿到了整个 Wi-Fi 视图浏览时间窗口,足够
32+
把成本平摊掉。
33+
34+
### Spec 说明
35+
`mdns-scanning` 能力之前保证「只看 Wi-Fi 视图的用户从不 import
36+
zeroconf」,这条不再成立——每次 TUI 启动都会在 mount 时 import
37+
zeroconf。这部分跑在 worker 上,用户看不到延迟;变更记录在
38+
OpenSpec change `prewarm-bonjour-at-mount`
39+
1140
## [1.0.9] — 2026-05-14
1241

1342
小但有用:一个查 diting 当前版本号的口子。
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-05-14
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
## Why
2+
3+
The Bonjour prewarm trigger landed in v1.0.x was "first time the
4+
user leaves the Wi-Fi view" — i.e. the wifi → BLE step. That
5+
window is long enough on the source-installed `uv run diting` build
6+
(`asyncio.to_thread(import …)` actually overlaps with the BLE
7+
view's reading time because `.py` file reads release the GIL during
8+
`open()`).
9+
10+
It's not enough for the **PyInstaller-frozen binary** (the curl-
11+
install path). PyInstaller's `PyiFrozenImporter` decompresses each
12+
imported module from a PYZ archive inside pure-Python code that
13+
holds the GIL the entire time. So `asyncio.to_thread` doesn't help
14+
— the import is effectively synchronous from the event loop's
15+
perspective. Users on the curl-installed v1.0.9 see the second `n`
16+
press (BLE → mDNS) hang for >1.5 s while zeroconf is still being
17+
unpacked.
18+
19+
## What Changes
20+
21+
- The Bonjour prewarm SHALL be triggered at TUI mount, not on the
22+
first wifi → BLE step. `App.on_mount` calls
23+
`_ensure_mdns_poller()` after scheduling the other pollers.
24+
- The existing wifi → BLE call from `action_toggle_view` stays as
25+
a safety net (idempotent gate handles it).
26+
- **BREAKING (spec only)**: the previous requirement "user who
27+
only uses Wi-Fi view never imports zeroconf" no longer holds.
28+
Every TUI session imports zeroconf at mount. Acceptable: the
29+
cost is amortised over the user's first ~5 s of reading the
30+
wifi view, which is dominated by their cognitive load, not the
31+
app's CPU.
32+
33+
## Capabilities
34+
35+
### New Capabilities
36+
<!-- none -->
37+
38+
### Modified Capabilities
39+
- `mdns-scanning`: prewarm trigger moves from "first wifi → BLE"
40+
to "TUI mount". Same underlying mechanism (idempotent
41+
`_ensure_mdns_poller` + worker), just earlier.
42+
43+
## Impact
44+
45+
- `src/diting/tui.py`: one new call (`self._ensure_mdns_poller()`)
46+
at the end of `App.on_mount`.
47+
- `tests/test_tui_smoke.py`: rename / rewrite the two
48+
Bonjour-lazy tests to reflect mount-time prewarm; update the
49+
`_inject_bonjour_devices` helper to pause polling so the
50+
mount-time consumer task can't overwrite the injection.
51+
- Same applies for the source build but the wifi-only-user
52+
optimisation it removes was always a minor savings.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## MODIFIED Requirements
2+
3+
### 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
25+
- **AND** no mDNS-related UI is shown
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
## 1. Move the prewarm trigger
2+
3+
- [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.
20+
- [x] 4.3 `openspec validate --specs --strict` passes.
21+
- [x] 4.4 `openspec validate prewarm-bonjour-at-mount --strict` passes.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "diting"
3-
version = "1.0.9"
3+
version = "1.0.10"
44
description = "macOS terminal listening post for Wi-Fi, BLE, link health, and the RF environment — your Mac hears more than it tells you"
55
readme = "README.md"
66
license = "MIT"

scripts/build_frozen.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ def main() -> None:
104104
# data files. They live under src/diting/data/ and are read
105105
# via importlib.resources at runtime.
106106
"--add-data", f"{REPO_ROOT / 'src' / 'diting' / 'data'}:diting/data",
107+
# Ship the `diting` package's own dist-info so
108+
# `importlib.metadata.version("diting")` can resolve at
109+
# runtime. Without this, the frozen binary's __version__
110+
# falls back to "0+unknown" (which is what v1.0.9 shipped:
111+
# `diting --version` printed `diting 0+unknown` and the TUI
112+
# title rendered `diting v0+unknown`). PyInstaller does not
113+
# copy a package's metadata by default — only modules and
114+
# data files referenced by static analysis.
115+
"--copy-metadata", "diting",
107116
# PyInstaller needs to know where to find the `diting`
108117
# package since the entry stub imports it. Pointing
109118
# --paths at src/ lets the analyzer walk the package

src/diting/tui.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4380,6 +4380,19 @@ async def on_mount(self) -> None:
43804380
exclusive=False,
43814381
name="latency",
43824382
)
4383+
# Pre-warm Bonjour as soon as the TUI mounts. The earlier
4384+
# "first time leaving Wi-Fi" trigger gave the source build
4385+
# enough window to absorb the `from .mdns import ...` import
4386+
# in `asyncio.to_thread`, but the PyInstaller-frozen binary's
4387+
# PyiFrozenImporter holds the GIL for the entire 1-2 s of
4388+
# decompression — `asyncio.to_thread` doesn't help there
4389+
# because the worker thread isn't actually I/O-blocked. By
4390+
# kicking off the prewarm at mount, the wifi view's reading
4391+
# time amortises the cost across both builds. The gate in
4392+
# `_ensure_mdns_poller` stays idempotent, so the explicit
4393+
# call from `action_toggle_view` (kept for safety) is a
4394+
# no-op after this.
4395+
self._ensure_mdns_poller()
43834396

43844397
def on_unmount(self) -> None:
43854398
# Flush + close the JSONL log on TUI exit so the file is

tests/test_helper.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,3 +546,21 @@ def test_helper_bundle_declares_appicon_and_ships_iconset():
546546
present = {p.name for p in iconset.iterdir() if p.suffix == ".png"}
547547
missing = required - present
548548
assert not missing, f"AppIcon.iconset is missing sizes: {sorted(missing)}"
549+
550+
551+
def test_frozen_build_copies_diting_metadata():
552+
"""The PyInstaller-frozen binary MUST ship `diting`'s dist-info
553+
so `importlib.metadata.version("diting")` resolves at runtime.
554+
Without it, the frozen binary's __version__ falls back to
555+
`0+unknown` — v1.0.9 shipped this way and the TUI title rendered
556+
`diting v0+unknown`. Guard against regression by asserting the
557+
build script still passes `--copy-metadata diting`."""
558+
repo_root = Path(__file__).resolve().parent.parent
559+
build_script = repo_root / "scripts" / "build_frozen.py"
560+
assert build_script.exists()
561+
text = build_script.read_text(encoding="utf-8")
562+
assert '"--copy-metadata"' in text and '"diting"' in text, (
563+
"scripts/build_frozen.py must pass `--copy-metadata diting` "
564+
"to PyInstaller so the frozen binary can read its own version "
565+
"via importlib.metadata. See the v1.0.9 -> v1.0.10 fix."
566+
)

0 commit comments

Comments
 (0)