Match cloud poll cmd and headers to the iOS app capture, and fix a shutdown hang - #91
Merged
Merged
Conversation
- INTESIS_CMD_STATUS now requests all five blocks (status, config, permissions, scenes, patterns, error) the official AC Cloud 3.3.3 iOS app always sends, byte-for-byte matching a Proxyman capture. - Add API_EXTRA_HEADERS (Accept, Accept-Encoding, Accept-Language) sent on every cloud poll, matching the same capture. These are set explicitly because aiohttp's own Accept-Encoding default depends on which optional codec packages happen to be installed, and it sends no Accept-Language at all.
stop() sets _stopping, then cancels the poll task and awaits it. That await could never return: on Python < 3.12 asyncio.wait_for discards a cancellation delivered while the future it is waiting on has already completed, and _handle_disconnect sets _poll_wakeup, which completes exactly that future. A disconnect landing in the same loop iteration as stop() left the poller looping on, having absorbed the only cancel it was ever sent. The poller now checks _stopping after each wait, so it retires on the flag rather than relying on a cancel that may not arrive. 3.12 rewrote wait_for on top of asyncio.timeout and propagates the cancel correctly; the check is harmless there and the fix holds on 3.10 through 3.13. This is what hung test_disconnect_does_not_reconnect in teardown - the test woke the poller and the fixture then called stop(). The suite now runs clean with nothing deselected, in 13s rather than timing out.
The package declared no floor, so pip would install it on any Python - including 3.10 and 3.11, where asyncio.wait_for discards a cancellation delivered while the future it is waiting on has already completed, and the poller's shutdown hung. Nothing tested those versions, which is how that survived. Sets the floor at 3.12, where wait_for is built on asyncio.timeout and propagates cancellation correctly, and widens the CI matrix to 3.12, 3.13 and 3.14 so every version claimed is a version tested. Classifiers updated to match. Reframes the poller's _stopping check accordingly: on the supported range it is belt-and-braces rather than a workaround, kept so shutdown does not rest on the cancellation semantics of one await inside the loop.
The file was redundant and behind: it carried Unreleased and 2.3.0, while the GitHub releases cover v2.0.0 through v2.3.0 with fuller notes for each. Nothing referenced it - setup.py builds long_description from the README alone, and there is no MANIFEST.in - so it was a second place to remember to update, kept in step by hand. Its Unreleased section (the web portal command fallback and SOCKET_CONNECT_TIMEOUT) is the basis for the 2.4.0 release notes, and stays available in history at 27a897d:CHANGELOG.md.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes the cloud HTTP poll look like the official app's, from a Proxyman capture of AC Cloud 3.3.3 on iOS — plus a supported-Python floor and a shutdown-path hardening found while chasing a hanging test.
The
cmdfieldThe app always asks for all five blocks, not just
status/config, and always includes the emptypermissionsstring and the error block'sculture:{"status":{"hash":"x"},"config":{"hash":"x"},"permissions":"","scenes":{"hash":"x"},"patterns":{"hash":"x"},"error":{"hash":"x","culture":"en"}}Verified byte-for-byte against the captured request. We don't parse
scenes/patterns/permissions/error, but sending the app's shape keeps our traffic indistinguishable from it.os=ios/osVersion=26.6already matched, so they're unchanged.Request headers
Adds
API_EXTRA_HEADERS—Accept,Accept-Encoding,Accept-Language— sent alongsideUser-Agenton every poll, matching the same capture.AcceptandAccept-Encodinghappen to coincide with aiohttp's defaults today, but they're set explicitly anyway: aiohttp buildsAccept-Encodingfrom whichever optional codec packages are installed, so it silently dropsbrin an environment without brotli. What our traffic looks like shouldn't depend on that. aiohttp sends noAccept-Languageat all.Content-Type: application/x-www-form-urlencodedandConnection: keep-alivealready match — aiohttp sends both for a dict-form POST over a persistent session (confirmed in its source), so neither needs an override.Verified by constructing a real
aiohttp.ClientRequestand comparing the prepared header set against the capture:python_requiresand CI coverageThe package declared no floor, so pip would install it on any Python — including 3.10 and 3.11, where
asyncio.wait_fordiscards a cancellation delivered while the future it is waiting on has already completed:stop()sets_stopping, cancels the poll task and awaits it, while_handle_disconnect()sets_poll_wakeup— completing exactly that future. A disconnect landing in the same loop iteration asstop()left the poller having absorbed its only cancel, looping on, with_cancel_task_if_existswaiting forever.Nothing tested those versions, which is how it survived: CI ran 3.14 only.
This sets the floor at 3.12, where
wait_foris built onasyncio.timeoutand propagates cancellation correctly, and widens the CI matrix to 3.12, 3.13 and 3.14 so every version claimed is a version tested. Classifiers updated to match.Poller shutdown check
_run_pollernow retires on the_stoppingflag after each wait rather than depending solely on the cancel arriving.To be clear about its status: on the supported range this is belt-and-braces, not a live bug fix. I verified on a real 3.12 interpreter that the tests pass with the check removed — the hang reproduces only on 3.10/3.11. It's kept because shutdown shouldn't rest on the cancellation semantics of one
awaitdeep inside the loop, andtest_stop_completes_when_a_disconnect_races_itpins the invariant_cancel_task_if_existsactually relies on: onecancel()must reap the poller.This is also what had been hanging
test_disconnect_does_not_reconnectfor 120s in fixture teardown on pre-3.12 interpreters.Verification
99 tests pass with nothing deselected, on both 3.11 and 3.12, in ~13s rather than timing out. ruff and pylint clean (10.00/10).
Version
Minor bump to 2.4.0.
Response hash tracking — sending the server's returned hashes back so it can skip resending unchanged blocks — builds on this and is deliberately left for a follow-up PR, since its response-parsing side rests on a protocol assumption worth soaking separately.