Skip to content

Commit f67fa00

Browse files
chenchaoyiclaude
andauthored
fix(event-log): session_meta carries at-launch SSID + gateway_ip (v1.7.1) (#121)
Pre-existing race shipped with v1.6.0 (when session_meta first landed) and surfaced by the v1.7.0 release-binary smoke audit: ```jsonl # Captured `diting monitor` against a host actually associated to tedo_5G: {"type":"session_meta","ssid":null,"gateway_ip":null, …} ← stale at write time {"type":"link_state","ssid":"tedo_5G","bssid":"40:fe:95:8a:3c:58", …} ← ms later, real state ``` `session_meta` is the first line of the JSONL stream and is supposed to carry the at-launch context downstream readers (the analyzer's "Scene + connection" header, the `--for-llm` prompt bundle, any third-party `jq` script) use to interpret the session. Writing `null` for SSID / gateway when the host is in fact associated made the analyzer report "session started disassociated" — wrong by construction. Root cause: both call sites — `_run_monitor` in `cli.py` and `DitingApp.__init__` in `tui.py` — invoked `emit_session_meta` synchronously during startup, but the first WiFiPoller snapshot (which populates `_latest_connection`) hadn't run yet. SSID + gateway_ip were never threaded into the call, so they defaulted to None. Fix: both call sites now invoke `backend.get_connection()` once, synchronously, BEFORE emitting `session_meta`. `get_connection()` on `MacOSWiFiBackend` is sync and cheap (reads SCDynamicStore / helper subprocess result, ~5ms). Failures (helper not ready, no Wi-Fi yet) are absorbed via try/except → values default to None, preserving the disassociated-at-launch path that no-Wi-Fi cold launches need. Verified against the live network: ```jsonl {"type":"session_meta","scene":"home","scene_source":"auto", "diting_version":"1.7.1","ssid":"tedo_5G","gateway_ip":"192.168.124.1", "hostname":"…"} ``` Tests (+2 new, 1049/1049 total): - `test_app_session_meta_carries_startup_ssid_and_gateway` — direct regression: DitingApp with a `_FakeBackend` that returns Connection(ssid="testnet", router_ip="10.0.0.1") writes a session_meta carrying both. - `test_app_session_meta_absorbs_get_connection_failure` — adversarial: backend whose `get_connection()` raises `OSError("helper not ready")` still produces a valid session_meta with ssid / gateway_ip = None and doesn't crash app startup. CHANGELOG + ZH CHANGELOG + TESTING.md (EN + ZH) updated. Version: `pyproject.toml` 1.7.0 → 1.7.1. Validates: pytest 1049/1049 ✓ openspec validate --specs --strict (22/22) ✓ Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9631ead commit f67fa00

9 files changed

Lines changed: 133 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,26 @@ behaviours between releases.
1111

1212
## [Unreleased]
1313

14+
## [1.7.1] — 2026-05-23
15+
16+
Patch release. **`session_meta` JSONL header now carries the at-
17+
launch SSID + gateway_ip** — pre-v1.7.1 the call ran before the
18+
first WiFi poll completed and the first line of every session log
19+
reported `ssid: null` / `gateway_ip: null` even when the host was
20+
associated. Downstream consumers (the analyzer, the `--for-llm`
21+
prompt bundle, third-party `jq` scripts) would misread the
22+
session as having started disassociated.
23+
24+
### Fixed
25+
- **`emit_session_meta` populates SSID + gateway from a synchronous
26+
startup `get_connection()`.** Both call sites (`_run_monitor` in
27+
`cli.py`, `DitingApp.__init__` in `tui.py`) now fetch the
28+
connection once before the JSONL header is written. Failure
29+
(helper not ready, no Wi-Fi yet) is absorbed as `None` so the
30+
no-Wi-Fi cold-launch path keeps working. Pre-existing race
31+
shipped with v1.6.0; surfaced by the v1.7.0 release-binary
32+
smoke audit.
33+
1434
## [1.7.0] — 2026-05-23
1535

1636
Minor release. **LAN identification expansion** — the LAN view now

docs/zh/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010

1111
## [Unreleased]
1212

13+
## [1.7.1] — 2026-05-23
14+
15+
Patch release。**`session_meta` JSONL 头部现在带启动时的 SSID + 网关 IP** —— v1.7.1 之前,emit_session_meta 在第一次 WiFi 轮询完成前调用,每个 session log 的首行都把 `ssid` / `gateway_ip` 写成 `null`,哪怕主机一直是连着 Wi-Fi 的。下游消费者(analyzer、`--for-llm` 提示包、第三方 `jq` 脚本)会把会话误读为"启动时未关联 Wi-Fi"。
16+
17+
### 修复
18+
- **`emit_session_meta` 在写头部前同步取一次 `get_connection()`,把 SSID + 网关填进去。** `_run_monitor`(cli.py)和 `DitingApp.__init__`(tui.py)两个调用点都改了。`get_connection()` 抛错时(helper 未就绪 / 没 Wi-Fi)吞掉异常,回退为 `None`,所以未关联场景的冷启动路径继续可用。这个时序 race 自 v1.6.0 起就有,由 v1.7.0 发版后的 release-binary 烟雾审计发现。
19+
1320
## [1.7.0] — 2026-05-23
1421

1522
Minor release。**LAN 识别能力扩展** —— LAN 视图现在能识别国内

docs/zh/TESTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@
196196
| Requirement | 测试 |
197197
|---|---|
198198
| `session_meta` 在第一行;带 scene + scene_source + diting_version + ssid + gateway_ip + hostname;emit 幂等;disabled logger 是 no-op;SSID / gateway 允许 null 写入 | `test_event_log.py::test_session_meta_writes_header_with_all_fields``::test_session_meta_is_first_when_emitted_first``::test_session_meta_is_idempotent``::test_session_meta_disabled_logger_is_no_op``::test_session_meta_accepts_null_ssid_and_gateway` |
199+
| **v1.7.1** —— `DitingApp.__init__``_run_monitor` 都在 emit `session_meta` 之前同步 `backend.get_connection()`,让 JSONL 头部带启动时的 SSID + 网关 IP,而不是 null。Backend 抛错(helper 未就绪、还没 Wi-Fi)时回退为 None | `test_tui_smoke.py::test_app_session_meta_carries_startup_ssid_and_gateway``::test_app_session_meta_absorbs_get_connection_failure` |
199200
| `--log``diting monitor` 输出字节相等 | `test_event_log.py::test_to_path_writes_appendable_jsonl``::test_unicode_user_strings_survive_readable`(共享 writer 类) |
200201
| 每个事件后强制 flush | `test_event_log.py::test_line_buffered_writes_are_visible_before_close` |
201202
| atexit 钩子优雅关闭 writer | (gap — 没有专门的测试;行为在 `test_line_buffered_writes_are_visible_before_close` 里间接覆盖) |

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.7.0"
3+
version = "1.7.1"
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"

src/diting/cli.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,24 @@ async def _run_monitor(
286286
# to the TUI's --log path so downstream readers don't branch on
287287
# source. Scene was resolved by main() before dispatching; we
288288
# only thread the source through here.
289+
#
290+
# Synchronously fetch the connection ONCE before emitting so
291+
# session_meta carries the at-launch SSID + gateway_ip rather
292+
# than null. Pre-v1.7.1 this call ran before any poll
293+
# completed and every session_meta line reported `ssid: null`
294+
# even when the host was associated. Failure (no Wi-Fi yet,
295+
# helper not ready) is absorbed as None so the disassociated-
296+
# at-launch path keeps working.
289297
from . import scene as _scene_mod
298+
try:
299+
startup_conn = backend.get_connection()
300+
except Exception:
301+
startup_conn = None
290302
logger.emit_session_meta(
291303
scene=_scene_mod.get_scene(),
292304
scene_source=scene_source,
305+
ssid=startup_conn.ssid if startup_conn else None,
306+
gateway_ip=startup_conn.router_ip if startup_conn else None,
293307
)
294308

295309
poller = WiFiPoller(backend)

src/diting/tui.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6211,13 +6211,28 @@ def __init__(
62116211
else EventLogger.disabled()
62126212
)
62136213
# Session header — written immediately so any subsequent
6214-
# emit_* (which may fire before the first ConnectionUpdate
6215-
# gives us SSID / gateway) lands AFTER the session_meta
6216-
# line. SSID / gateway are filled in here if known at this
6217-
# point, else null; downstream consumers expect the field
6218-
# to exist either way. No-op on the disabled logger.
6214+
# emit_* lands AFTER the session_meta line. Synchronously
6215+
# fetch the current connection ONCE here (before the
6216+
# WiFiPoller's async loop has had a chance to publish its
6217+
# first snapshot) so SSID + gateway_ip carry the actual
6218+
# at-launch values rather than null. Pre-v1.7.1 the call
6219+
# ran before the first poll completed, so every session_meta
6220+
# reported `ssid: null` / `gateway_ip: null` even when the
6221+
# user was associated — broke the analyzer's "session
6222+
# started on AP X" timeline. `get_connection()` is sync
6223+
# and cheap; failure (no Wi-Fi yet, helper not ready) is
6224+
# absorbed as None so the no-Wi-Fi path keeps working.
6225+
try:
6226+
startup_conn = backend.get_connection()
6227+
except Exception:
6228+
startup_conn = None
6229+
startup_ssid = startup_conn.ssid if startup_conn else None
6230+
startup_gateway = startup_conn.router_ip if startup_conn else None
62196231
self._event_logger.emit_session_meta(
6220-
scene=scene, scene_source=scene_source,
6232+
scene=scene,
6233+
scene_source=scene_source,
6234+
ssid=startup_ssid,
6235+
gateway_ip=startup_gateway,
62216236
)
62226237
self._event_log_path = event_log_path
62236238
# Per-(event_type, target) last-emit monotonic timestamp,

tests/TESTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ When a new Requirement lands in any spec, an entry MUST be added here
205205
| Requirement | Test |
206206
|---|---|
207207
| `session_meta` line is first; carries scene + scene_source + diting_version + ssid + gateway_ip + hostname; emit is idempotent; disabled logger is no-op; null SSID / gateway are written through | `test_event_log.py::test_session_meta_writes_header_with_all_fields`, `::test_session_meta_is_first_when_emitted_first`, `::test_session_meta_is_idempotent`, `::test_session_meta_disabled_logger_is_no_op`, `::test_session_meta_accepts_null_ssid_and_gateway` |
208+
| **v1.7.1** — Both `DitingApp.__init__` and `_run_monitor` synchronously fetch `backend.get_connection()` BEFORE emitting `session_meta`, so the JSONL header carries the at-launch SSID + gateway_ip rather than null. Backend failures (helper not ready, no Wi-Fi yet) are absorbed as None | `test_tui_smoke.py::test_app_session_meta_carries_startup_ssid_and_gateway`, `::test_app_session_meta_absorbs_get_connection_failure` |
208209
| `--log` and `diting monitor` produce byte-identical streams | `test_event_log.py::test_to_path_writes_appendable_jsonl`, `::test_unicode_user_strings_survive_readable` (single shared writer class) |
209210
| Writer flushes after every event | `test_event_log.py::test_line_buffered_writes_are_visible_before_close` |
210211
| atexit hook closes writer cleanly | (gap — no direct test; behaviour validated by `test_line_buffered_writes_are_visible_before_close`) |

tests/test_tui_smoke.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,3 +1126,70 @@ async def go():
11261126
await pilot.press("q")
11271127

11281128
asyncio.run(go())
1129+
1130+
1131+
# ---------- v1.7.1 regression: session_meta carries at-launch SSID + gateway ----------
1132+
1133+
1134+
def test_app_session_meta_carries_startup_ssid_and_gateway(tmp_path):
1135+
"""v1.7.1 regression: DitingApp.__init__ must synchronously fetch
1136+
backend.get_connection() and pass ssid + gateway_ip into
1137+
emit_session_meta. Pre-v1.7.1 the call ran before the first
1138+
WiFi poll completed and every session_meta carried `ssid: null`
1139+
/ `gateway_ip: null` even when the host was associated."""
1140+
import json
1141+
1142+
log_path = tmp_path / "events.jsonl"
1143+
DitingApp(
1144+
_FakeBackend(),
1145+
_INVENTORY,
1146+
event_log_path=str(log_path),
1147+
)
1148+
# __init__ alone (no .run()) is enough — emit_session_meta is
1149+
# called synchronously inside __init__.
1150+
[first] = log_path.read_text().splitlines()
1151+
meta = json.loads(first)
1152+
assert meta["type"] == "session_meta"
1153+
assert meta["ssid"] == "testnet" # from _FakeBackend.get_connection
1154+
assert meta["gateway_ip"] == "10.0.0.1" # ditto
1155+
assert meta["diting_version"] # populated
1156+
assert meta["scene"] == "home" # DitingApp default
1157+
1158+
1159+
class _BackendThatRaisesOnGetConnection(WiFiBackend):
1160+
"""Backend whose get_connection raises (helper not ready, etc.).
1161+
The session_meta path must absorb the exception and write null
1162+
for ssid / gateway_ip rather than crashing app startup."""
1163+
1164+
name = "raising"
1165+
1166+
def __init__(self) -> None:
1167+
self._helper_path = None
1168+
1169+
def get_connection(self) -> Connection | None:
1170+
raise OSError("helper not ready")
1171+
1172+
def scan(self) -> list[ScanResult]:
1173+
return []
1174+
1175+
def permission_state(self) -> Any:
1176+
return "unknown"
1177+
1178+
1179+
def test_app_session_meta_absorbs_get_connection_failure(tmp_path):
1180+
"""When backend.get_connection() raises (e.g. helper bundle not
1181+
finished launching), session_meta still writes successfully with
1182+
ssid / gateway_ip set to None — the app startup must not crash."""
1183+
import json
1184+
1185+
log_path = tmp_path / "events.jsonl"
1186+
DitingApp(
1187+
_BackendThatRaisesOnGetConnection(),
1188+
_INVENTORY,
1189+
event_log_path=str(log_path),
1190+
)
1191+
[first] = log_path.read_text().splitlines()
1192+
meta = json.loads(first)
1193+
assert meta["type"] == "session_meta"
1194+
assert meta["ssid"] is None
1195+
assert meta["gateway_ip"] is None

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)