Skip to content

Add On Air reconnect tests and skip a disconnect in progress - #276

Draft
evnchn wants to merge 2 commits into
mainfrom
test/6212-air-disconnecting-guard
Draft

Add On Air reconnect tests and skip a disconnect in progress#276
evnchn wants to merge 2 commits into
mainfrom
test/6212-air-disconnecting-guard

Conversation

@evnchn

@evnchn evnchn commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Verdict: the in-flight On Air reconnect fix (zauberzeug/niceguizauberzeug#6213, branch fix/air-stale-connected-flag) is correct for the reported bug but reconnects a device that is deliberately shutting down. Its guard treats every non-'connected' Engine.IO state as stale, and 'disconnecting' is one of them. One-word fix plus the regression tests that PR is missing.

This branch is fix/air-stale-connected-flag + 1 commit. Base is main, so the diff below also contains that PR's own commit. The reviewable commit is Add On Air reconnect tests and skip a disconnect in progress.

Motivation

Two things, both on top of the existing fix rather than competing with it:

  1. A bug in the in-flight fix. Engine.IO has three states — 'connected', 'disconnecting', 'disconnected'. The guard is if self.relay.eio.state == 'connected': return, so a keep-alive tick that lands while a deliberate disconnect is running classifies it as stale, replaces the client and reconnects. app.shutdown()air.disconnect() is exactly that path, and the keep-alive timer fires every 5 s.
  2. No test coverage. nicegui/air.py had none, so neither the original bug nor this regression is pinned. The stuck-flag mechanism is subtle enough that a future refactor could silently reintroduce it.

Implementation

-            if self.relay.eio.state == 'connected':
+            if self.relay.eio.state != 'disconnected':  # "disconnecting" means a deliberate disconnect is running
                 return

Testing for the definitively dead state instead of the healthy one is strictly narrower and still catches the reported bug: Engine.IO's read loop ends in _reset(), which sets state = 'disconnected', so the stuck state is always exactly connected == True and eio.state == 'disconnected'.

Plus tests/test_air.py — three cases against a FakeRelay that reproduces the two python-socketio behaviours the whole bug rests on: disconnect() leaves connected set once the transport is already dead, and connect() rejects while connected is True. Without both, the tests would be vacuous.

The fixture collects every relay client the Air instance creates, so the tests are agnostic to how the stale client is dealt with — they pass equally against "replace the client" and against "clear the flag in place", and would survive a change of approach.

Verification — the full A/B matrix (each test seen to fail on the code that lacks its fix)
nicegui/air.py under test …transport_died_silently …while_healthy …while_disconnecting
upstream/main — no fix FAIL assert 0 == 1
fix/air-stale-connected-flag — the in-flight fix FAIL assert 1 == 0
this branch

Row 1 proves the tests actually pin the original bug. Row 2 is the finding — and proves the one-word change is load-bearing rather than cosmetic.

=== 2. PR6213 head, no guard fix ===
FAILED tests/test_air.py::test_no_reconnect_while_disconnecting
  AssertionError: a disconnect in progress must not be reconnected by the keep-alive timer
  assert 1 == 0
------------------------------ Captured log call -------------------------------
WARNING  nicegui.air:air.py:216 Socket.IO claims to be connected while Engine.IO is not. Replacing the client.
1 failed, 2 passed

=== 3. upstream/main baseline, no fix at all ===
FAILED tests/test_air.py::test_reconnect_after_transport_died_silently
  AssertionError: a stale "connected" flag must not block the reconnect
1 failed, 2 passed

=== 1. with guard fix ===
3 passed in 0.03s

Note the captured WARNING in row 2 — the in-flight fix does not merely fail to skip the shutdown, it actively logs that it is replacing the client and reconnects.

Local gates on this branch:

pre-commit run --files nicegui/air.py tests/test_air.py   ruff/autopep8/quote-fixer/codespell — all Passed
mypy ./nicegui                                            Success: no issues found in 245 source files
pylint ./nicegui                                          10.00/10
pytest tests/test_air.py -rs                              3 passed, 0 skipped
Where the finding came from, and the library facts behind it

The 'disconnecting' edge was raised by Codex (GPT-5, a different model lineage) reviewing an earlier, independent attempt at this fix, briefed to assume the change was broken:

MINOR — the guard treats any Engine.IO state other than 'connected' as stale […] If the 5-second keepalive fires while a clean disconnect is in progress, this path can classify 'disconnecting' as stale and reconnect. […] it is the main behavioral edge I would call out for maintainers.

It was flagged against a private draft; checking it against fix/air-stale-connected-flag showed the same guard shape there, and the test above confirmed it empirically rather than by reading.

Supporting facts, read from the installed libraries rather than from memory:

  • engineio/async_client.py sets exactly three state values: 'disconnecting' (during disconnect()), 'disconnected' (_reset(), and the base client's initial value), 'connected'.
  • A dead transport always ends at 'disconnected': the read loop's tail runs _trigger_event('disconnect') then _reset().
  • Confirmed against the real socketio.AsyncClient that disconnect() leaves connected == True when eio.state != 'connected', and that connect() then raises ConnectionError('Already connected') — which air.py swallows at debug level. That is the permanent stuck state, and it is why replacing the client (or clearing the flag) is necessary at all.
Scope — deliberately not included
  • The end-to-end relay liveness probe. The upstream issue asks for two fixes; both this branch and the branch it builds on implement only the first (reconcile the stale flag). The second — periodically fetching remote_url and resetting on a repeated 404 — also covers "the relay forgot the device / ready never arrived", which remains unfixed. It carries its own policy questions (probe cadence, consecutive-404 threshold, and correctly doing nothing when the relay is unreachable, since that is an internet outage) and is implemented downstream in air-link.
  • No live-relay reproduction. The evidence is library-level mechanism plus unit tests. Neither the original failure nor the shutdown-reconnect has been reproduced against a real relay with a genuinely crashed transport.
  • Rebasing onto fix/air-stale-connected-flag as the PR base. Kept main as the base, so the two commits are visible together.

Progress

  • The PR title is a short phrase starting with a verb like "Add ...", "Fix ...", "Update ...", "Remove ...", etc.
  • The implementation is complete. (Draft: it targets an in-flight PR and should land as part of it, not separately.)
  • This PR does not address a security issue.
  • Pytests have been added.
  • Documentation is not necessary.
  • No breaking changes to the public API.

Opened by an autonomous agent under a fork-only charter — not pushed upstream, and no comment left on the upstream PR. Supersedes the earlier duplicate draft in this fork (fix/6212-air-reconnect), which was opened before that in-flight PR was discovered.

evnchn and others added 2 commits July 28, 2026 12:35
`Air` decides whether it still needs to reconnect by checking one thing,
`self.relay.connected`. That flag can get permanently stuck on `True`
after the connection has died, and the keep-alive timer then returns
early every five seconds forever, leaving the device unreachable until
the process restarts.

python-socketio assigns `connected = True` as the last statement of
`connect()`, while the cleanup that would clear it is guarded by
`if self.connected:`. A transport that dies in between falls through the
gap: the cleanup sees an unset flag and does nothing, then `connect()`
finishes and sets it anyway.

The flag cannot be cleared afterwards -- Engine.IO's `disconnect()` is
guarded by `if self.state == 'connected'`, so it never emits the event
Socket.IO needs, and `shutdown()` delegates to it. So instead of
repairing the wedged client, discard it and build a fresh one through
the public API; handler registration moves into `_create_relay()` with
the handler bodies unchanged.

Auto-reconnect is disabled so the keep-alive timer is the only reconnect
driver. This matters for the replacement: a discarded client with
auto-reconnect still enabled keeps its own reconnect loop running and
could open a second connection to the relay, and `shutdown()` cannot
stop it. In-flight streams are closed on replacement, since the
discarded client can no longer deliver `close-stream`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Treat only the definitively dead "disconnected" transport state as stale,
so a keep-alive tick during a deliberate disconnect does not reconnect.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant