Skip to content

fix: reuse a shared awscrt ClientBootstrap to stop event loop thread leak - #75

Merged
dahlb merged 1 commit into
dahlb:mainfrom
mabrews:fix/shared-client-bootstrap
Aug 25, 2026
Merged

fix: reuse a shared awscrt ClientBootstrap to stop event loop thread leak#75
dahlb merged 1 commit into
dahlb:mainfrom
mabrews:fix/shared-client-bootstrap

Conversation

@mabrews

@mabrews mabrews commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

get_rest_devices() built a fresh io.EventLoopGroup(1) + DefaultHostResolver +
ClientBootstrap on every call. awscrt starts a native thread per EventLoopGroup
and only stops it when the native resource is destroyed. Nothing in the shutdown
path ever destroys it: each device's shadow subscriptions hold callbacks that
reference the device, which references the connection, which owns the bootstrap —
so the whole graph stays reachable even after disconnect().

Home Assistant's ha_hatch integration rebuilds this connection every time the
AWS credentials expire, which Hatch issues hourly, so a long-running instance
strands one EventLoopGroup thread per hour indefinitely. Measured on my own
instance: 215 leaked AwsEventLoop threads after 8.7 days of uptime, an exact
match for the reconnect count, costing roughly 80 MB/day.

Fix: use io.ClientBootstrap.get_or_create_static_default(), awscrt's own
process-wide default, instead of allocating a new one per call.

Test plan

  • 3 new regression tests asserting the shared bootstrap is used, no
    EventLoopGroup/DefaultHostResolver are built per call, and a second
    connection reuses the same bootstrap instance. All fail against the
    pre-fix code (AttributeError/mock-call-count mismatches), confirming
    they aren't vacuous.
  • Full suite passes: 28/28 (32/32 once the follow-up PR #TBD is included).
  • Deployed to my own Home Assistant via a git+https requirement pointed at
    this branch and soaked in production. AwsEventLoop thread count held flat
    at 2 across 137 hours of uptime (vs. 215 pre-fix over 8.7 days), and open fds
    stayed flat at 72-77 (vs. 434 pre-fix).

There is a second, independent leak in the same area — shadow subscriptions are
never unsubscribed, so the Python-side connection graph also outlives
disconnect(). That's a separate PR on top of this branch, since the two are
independently reviewable and this one already has its own soak evidence.

…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>
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