Skip to content

fix: unsubscribe shadow topics so reconnects release the connection graph - #76

Merged
dahlb merged 4 commits into
dahlb:mainfrom
mabrews:fix/unsubscribe-shadow-topics
Aug 25, 2026
Merged

fix: unsubscribe shadow topics so reconnects release the connection graph#76
dahlb merged 4 commits into
dahlb:mainfrom
mabrews:fix/unsubscribe-shadow-topics

Conversation

@mabrews

@mabrews mabrews commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #75 — GitHub can only diff against main, so this PR's diff includes
that commit until #75 merges. The only new commits here are a284bf7 and
051854a; once #75 lands this will shrink to just those.

ShadowClientSubscriberMixin.__init__ subscribes to two AWS IoT shadow topics per
device and only logged the returned unsubscribe handles, never storing or calling
them. There was no unsubscribe() anywhere in the library.

awscrt holds a reference from native code to each subscription callback, and both
callbacks close over self. Python's garbage collector can't see those
references, so the device, its shadow client, and the TLS context and MQTT
buffers underneath stayed alive even after disconnect() closed the socket and
every Python caller had dropped its reference.

Callers that rebuild the connection therefore strand one full connection graph per
rebuild. ha_hatch rebuilds hourly, on AWS credential expiry, so a long-running
Home Assistant instance accumulates one per hour for the life of the process —
confirmed via HA's built-in memory profiler: 140 live RestIot objects after 137
hours of uptime, an exact 1:1 with the reconnect count, costing roughly 28 MB/day
on top of the thread leak fixed in #75.

Fix: record the topics returned from each subscribe call, and add unsubscribe()
to release them. Tolerant of an already-dead connection — the UNSUBACK never
arrives, but tearing the connection down releases the same native references, so
the topics are cleared either way.

A companion ha_hatch PR calls this from the coordinator's teardown path, before
disconnecting.

Test plan

  • 4 new tests: topics are retained on subscribe, unsubscribe() releases all of
    them, it's idempotent (the coordinator can call it from both the reconnect and
    shutdown paths), and it still clears state when the connection is already dead.
    All 4 fail against the pre-fix code (AttributeError: no attribute 'unsubscribe').
  • Full suite passes: 32/32.
  • Deployed to my own Home Assistant alongside fix: reuse a shared awscrt ClientBootstrap to stop event loop thread leak #75 and soaked for 72 hours
    continuous uptime. Measuring the identical 40-64h uptime window the fix: reuse a shared awscrt ClientBootstrap to stop event loop thread leak #75-only
    soak used (for a direct comparison): memory growth dropped from +33.3 MB/day to
    +0.7 MB/day, effectively flat. Zero shadow unsubscribe failed log entries
    across the whole run.

mabrews and others added 4 commits August 15, 2026 19:17
…leak

get_rest_devices built its own EventLoopGroup, DefaultHostResolver and
ClientBootstrap on every call. awscrt starts a native event loop thread per
EventLoopGroup and only stops it when the native resource is destroyed, which
never happens here: the per-device shadow subscriptions hold callbacks that
reference the device, which references the connection, which owns the
bootstrap, so the graph stays reachable even after disconnect().

Callers rebuild the connection whenever the AWS credentials expire, and Hatch
issues those hourly, so every refresh stranded another thread along with its
pipe pair and CRT buffers. On a long-running Home Assistant instance this
showed up as ~24 leaked AwsEventLoop threads per day and steady RSS growth;
one install reached 213 leaked threads over 8 days of uptime.

Use awscrt's process-wide static default bootstrap instead, which keeps the
cost flat at a single event loop thread no matter how often we reconnect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…raph

ShadowClientSubscriberMixin subscribed to update/accepted and get/accepted in
__init__ and threw the returned topics away, logging them and nothing more.
There was no unsubscribe path anywhere in the library.

awscrt holds a reference from native code to each subscription callback, and
both callbacks close over self. Python's garbage collector cannot see those
references, so the device, its shadow client, and the TLS context and MQTT
buffers under them stayed alive even after disconnect() closed the socket and
every Python caller had dropped its reference.

Callers that rebuild the connection therefore stranded one full connection
graph per rebuild. ha_hatch rebuilds whenever the AWS credentials expire, which
Hatch issues hourly, so a long-running Home Assistant accumulated one per hour
for the life of the process -- measured at 140 live RestIot objects after 137
hours of uptime, an exact 1:1 with the reconnect count, worth ~28 MB/day.

Record the topics and add unsubscribe() to release them. Tolerates an already
dead connection: the UNSUBACK never arrives, but tearing the connection down
releases the same native references, so the topics are cleared either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016q4bTcKK8Peja9RBC3jmKd
No behavior change; the rest of this file predates black adoption and is left
as-is to keep the diff focused, but new code should match project style.
@dahlb
dahlb merged commit 57aaf80 into dahlb:main Aug 25, 2026
3 checks passed
mabrews added a commit to mabrews/ha_hatch that referenced this pull request Aug 26, 2026
The coordinator's teardown now calls RestDevice.unsubscribe(), which only
exists as of hatch_rest_api 1.34.4 (dahlb/hatch_rest_api#76).
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.

2 participants