Skip to content

Reuse HTTP connections and add close() to both clients - #141

Draft
zeevmoney wants to merge 39 commits into
per-16337/api-coveragefrom
per-16344/session-reuse
Draft

zeevmoney wants to merge 39 commits into
per-16337/api-coveragefrom
per-16344/session-reuse

Conversation

@zeevmoney

@zeevmoney zeevmoney commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Linear issues

  • PER-16344: reuse one HTTP session per client instead of opening a connection for every request.

Why

Every request opened and closed its own aiohttp.ClientSession: the five HTTP methods of SimpleHttpClient (used by permit.api.*, permit.elements and permit.pdp_api.*) and the five PDP calls of the enforcer (check, bulk_check, authorized_users, get_user_permissions, get_user_tenants). Each call paid for a new TCP connection, plus a TLS handshake when the PDP or the API is reached over https. The blocking client also created a new event loop for every call.

What changed

Async client (permit.Permit)

  • permit/utils/http_sessions.py: LoopSessions keeps one aiohttp session per event loop, opened by the first request from that loop. Each client has two: one for the Permit API, one for the PDP. Sessions carry no headers, base URL or timeout: each request passes its own, resolved as ClientSession(base_url=...) resolved it, so the bytes on the wire are unchanged (the existing wire tests pass unchanged). Sessions use TCPConnector(limit=0) and DummyCookieJar.
  • Permit._share_sessions() gives the client's two sessions to every API object, HTTP client and the enforcer. No constructor signature changed.
  • await permit.close() and async with Permit(...) as permit:. close() closes the session of its own loop, marks closed the sessions of closed loops, and hands the close to loops running in other threads, waiting for each while that loop runs. The sessions are closed side by side: one that fails to close does not stop the others, and the first error is raised afterwards. A session still open afterwards (its loop stopped first, its close failed, or close() was cancelled) is kept for the next close() or request. close() can be called again, and a request after it opens new connections.
  • Without close(), sessions are closed when a loop shuts down its async generators (asyncio.run(), asyncio.Runner, pytest-asyncio), when the client is garbage collected while its loop runs (also through the cycle collector), and at interpreter exit. A forked child sets the sessions it inherits aside and opens its own.

Blocking client (permit.sync.Permit)

  • permit/utils/sync.py: each client runs its blocking calls, and those of every API object it holds, on one event loop in a daemon thread named permit-sync-loop, started on the first call. The deprecation call site is set in each call's task, the caller's other context variables reach it, many threads can share one client, and a call's exception is raised in the caller with its original type, traceback and cause.
  • permit.close() and with Permit(...) as permit: wait for the calls in flight, close the sessions on the background loop, then stop and join the thread. A call or close() that another thread makes meanwhile waits for it to finish. A blocking call or close() made on the client's own background thread raises RuntimeError instead of deadlocking. async with on the blocking client raises TypeError, and type checkers reject it.
  • A client that is never closed has its thread stopped when it is garbage collected, or at interpreter exit, where calls still in flight are cancelled so the exit is never held up. A forked child starts a thread of its own, including when it was forked during a close().

Both clients

  • With proxy_facts_via_pdp on, wait_for_sync() yields a copy that uses its parent's sessions (and, on the blocking client, its thread). The copy's close() does nothing, and the parent's close() closes them. With it off (the default), wait_for_sync() yields the client itself.
  • README: a "Connections" section. CONTRIBUTING: a "Connection reuse" section (the keep-alive test server and the benchmark command).
  • Tests: tests/keepalive_server.py, a local HTTP/1.1 server that keeps connections open and counts them; tests/test_async_session_lifecycle.py; tests/test_sync_lifecycle.py; tests/benchmark_connection_reuse.py, with tests/test_benchmark_connection_reuse.py running it in the offline suite; and the async lifecycle in tests/type_check/consumer.py.

Behaviour changes

  • Connections are reused: per client, one pool for the API and one for the PDP, per event loop. Idle connections stay open for aiohttp's default keep-alive of 15 s, and each pool caches DNS for aiohttp's default 10 s.
  • New public API: Permit.close(), Permit.__aenter__/__aexit__, permit.sync.Permit.close(), __enter__/__exit__.
  • permit.sync.Permit runs its calls on a daemon thread per client, started on the first call. Calls from many threads run concurrently on that loop. A call or close() made on that thread raises RuntimeError; before, such a call ran in a worker thread.
  • aiohttp's single retry on a dropped keep-alive connection now applies to GET, PUT and DELETE requests. POST and PATCH requests are not retried, so if a server drops an idle connection just as it is reused, check() can fail with PermitConnectionError.
  • An async client used on a loop closed with loop.close() alone leaves that loop's keep-alive connections open until the garbage collector frees them, and Python reports each with a ResourceWarning. Before, every request closed its own connection.
  • Importing permit registers atexit hooks and, outside Windows, os.register_at_fork hooks. On Python 3.12+, forking after a blocking client has made a call triggers Python's own DeprecationWarning about forking a process that runs threads.
  • SimpleHttpClient (internal) raises TypeError for client_config keys other than base_url, headers and timeout, and no longer writes timeout into the dict it is given. The unused **kwargs passthrough of both _build_http_client methods is gone.
  • permit/api/base.py imports yarl and multidict directly, so both are now declared in [project].dependencies: multidict>=6.7.0,<7 and yarl>=1.21.0,<2. Both are aiohttp dependencies already. These floors are the first releases with wheels for every supported Python (3.10 to 3.14t), so they are higher than aiohttp's own floors.
  • SyncClass objects that no client holds, such as SyncPermitApiClient(config) built directly, still run each call in an event loop of their own.

Release notes

Connections are now reused. Each client keeps one HTTP connection pool for the Permit API and one for the PDP, instead of opening a new connection, and a TLS handshake, for every request. In a local benchmark of 500 sequential check() calls on loopback, each client opened 1 connection instead of 500. The median time per call went from about 0.39-0.44 ms to 0.17-0.18 ms on the async client, and from 0.52-0.54 ms to 0.23-0.24 ms on the blocking client. Against a remote PDP or over https, each reused connection also saves a round trip and a TLS handshake.

  • New: await permit.close() and async with Permit(...) as permit: on the async client; permit.close() and with Permit(...) as permit: on permit.sync.Permit. close() can be called again, and a closed client reconnects on its next call.
  • With proxy_facts_via_pdp on, a client yielded by wait_for_sync() uses its parent's connections, and its own close() does nothing. With it off (the default), wait_for_sync() yields the client itself.
  • The async client keeps its connections per event loop. They are closed automatically when asyncio.run() (or anything else that shuts down the loop's async generators) ends, when the client is garbage collected while its loop runs, or at interpreter exit. If you drive an event loop yourself, call await permit.close() on it before loop.close(). A loop closed without that leaves its connections open until the garbage collector frees them, and Python then reports a ResourceWarning, which fails test suites that turn warnings into errors.
  • permit.sync.Permit now runs its calls on one event loop per client, in a background daemon thread it starts on the first call, so calls from every thread share the client's connections. close() waits for calls in flight, and a call or close() made from another thread meanwhile waits for it. A client that is never closed is cleaned up when it is garbage collected or at interpreter exit, and the thread never blocks exit. A blocking call or close() made on the client's own background thread raises RuntimeError instead of deadlocking. async with on the blocking client raises TypeError, and type checkers reject it.
  • A child process created by fork() leaves its parent's connections alone and opens its own.
  • Because connections are pooled, aiohttp's own single retry on a dropped keep-alive connection now applies to GET, PUT and DELETE requests. POST and PATCH requests, such as check() and the API's update calls, are not retried.

How it was tested

  • Offline suite on Python 3.11.14: 787 passed, 3 skipped, 0 warnings (filterwarnings=error), 5 runs on each pydantic lane. The base had 661 passed, 3 skipped.
  • Also on Python 3.10.19, 3.12.12, 3.13.9, 3.14.7 and 3.14.7 free-threaded (2 runs each), and on 3.10 with the floor dependencies (aiohttp 3.14.3, pydantic 1.10.18): 787 passed each time.
  • New lifecycle tests count connections at the keep-alive server. They cover:
    • reuse, and one client across two successive asyncio.run() calls;
    • close(), the context managers, idempotent close and use after close;
    • many threads on one blocking client, and a blocking call from a thread that runs a loop;
    • re-entrant calls, close() racing calls and a second close();
    • loops in other threads that end or stop during close(), and sessions that fail to close;
    • fork, and fork during close();
    • garbage collection and interpreter exit with no warning;
    • the daemon thread stopped by close(), and timeouts keeping their traceback.
  • mypy clean on both pydantic lanes. Typing surface: 4 passed on each lane. pytest --collect-only -m e2e: 42 collected. CI script tests: 223 passed. API coverage report: exit 0. pre-commit: all hooks pass. uv lock --check, actionlint and zizmor: clean.
  • Benchmark, uv run --locked python -m tests.benchmark_connection_reuse --calls 500, 3 runs each on 127.0.0.1 (Python 3.11.14, macOS arm64). The base numbers run the same script against the permit package of ef5a791:
Version Client Connections p50 ms mean ms
base ef5a791 async 500 0.385-0.436 0.408-0.526
base ef5a791 sync 500 0.522-0.544 0.533-0.561
this PR async 1 0.172-0.183 0.190-0.198
this PR sync 1 0.226-0.240 0.231-0.255
  • Mutation testing of the lifecycle tests: 27 mutants in the last round, 26 killed and 1 equivalent survivor (a filter on gather outcomes no reachable path distinguishes). Earlier rounds checked the async and sync units and the merge.
  • e2e tests were type-checked and collected only. CI runs them.

Owner actions before merge

  • Squash-merge: the intermediate commits d676947, a50aac3 and b9eee07 fail tests/test_fix_sync_parity.py (the async close() lands before the blocking client overrides it). Every later commit passes the offline suite.
  • Confirm the connection settings: no cap on concurrent connections (TCPConnector(limit=0)), aiohttp's 15 s keep-alive, and no retry of POST and PATCH requests on a dropped pooled connection.
  • Accept the documented limit for loops closed with loop.close() alone (a ResourceWarning at garbage collection).
  • Pre-existing and not changed here: a request timeout reaches the caller as a bare asyncio.TimeoutError, and an api_url or pdp with a path breaks permit.api, permit.elements and permit.pdp_api.
  • Free-threaded Python 3.14 is not in the CI matrix; it was checked locally.

🤖 Generated with Claude Code

zeevmoney and others added 30 commits October 2, 2026 01:15
pytest-httpserver closes the connection after every response, so it
cannot show whether a client reuses connections. This server keeps each
connection open until the client closes it, answers every request with
the same JSON body, and counts the connections it accepted, the ones
still open and the requests it read. It can delay its answers and
notices a client that hangs up while it waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
permit.sync.Permit used to run every blocking call in an event loop of
its own, so no HTTP session could outlive a call (PER-16344). Each client
now starts a daemon thread with one event loop on its first call, and
every blocking method of the client and of the API objects it hands out
is submitted to that loop and waited for, from any number of threads.

What the old wrapper guaranteed still holds: the call's coroutine sees
the caller's context variables, including the call site that deprecation
warnings name; an exception reaches the caller with its type and
traceback; a call from a thread that runs an event loop works; and
Ctrl+C while waiting cancels the call. A blocking call or close() made
on the loop's own thread raises RuntimeError instead of deadlocking.

close() and `with Permit(...) as permit:` wait for the calls in flight,
close the client's sessions on the loop and stop the thread. close() is
idempotent and the client stays usable: the next call starts a new
thread. A wait_for_sync() copy shares the client's loop, and closing
either closes the sessions of both. A client that is never closed has
its sessions closed when it is garbage collected and its thread stopped
once nothing references the loop; at interpreter exit, the calls still
in flight are cancelled and every loop is closed. A process forked after
a call starts a thread of its own on its next call.

Objects of a SyncClass class that no client holds keep running each
call in an event loop of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For the async and the sync client, it makes N check() calls one after
the other against the local server that counts TCP connections, and
prints how many connections they opened and how long each call took
(mean, p50, p95). Run it with
`uv run --locked python -m tests.benchmark_connection_reuse --calls 200`.
It calls close() only where the client has one, so it also measures
releases that predate it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every request opened and closed its own aiohttp.ClientSession, so each
call paid for a new TCP connection, and a TLS handshake to a remote PDP
or API (PER-16344).

The client now keeps one session for the Permit API and one for the PDP
per event loop, created by the first request from that loop and shared
by all of its APIs. A session belongs to the loop that made it, so a
client used under successive asyncio.run() calls gets one per run.

The sessions carry no headers, base URL or timeout: each request brings
the ones its own session carried before. They keep no cookies, as a
session used for one request kept none, so the bytes on the wire are
the same. The base URL is resolved and checked per
request as aiohttp.ClientSession(base_url=...) did it. The connector
keeps aiohttp's default keep-alive and has no connection cap, as when
every request had a session of its own.

A session is closed when its loop shuts down its async generators, as
asyncio.run() and asyncio.Runner do, or when its client is garbage
collected while the loop runs. The session of a loop closed without
that is dropped by the next request from another loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`await permit.close()` closes the client's sessions: the one of the loop
it runs on, those of loops already closed, and those of loops running in
other threads, on those loops, waiting for them. A loop that is neither
running nor closed keeps its session until it shuts down or close() runs
on it. close() can be called again, and a request sent after it opens
new connections. `async with Permit(...) as permit:` closes the client
on exit.

A client yielded by wait_for_sync() shares the sessions of the client
it was made from and leaves closing them to that client: its close()
does nothing.

permit.sync.Permit inherits this coroutine. Until it overrides close()
with a blocking one, tests/test_fix_sync_parity.py reports close() as
still async on the blocking client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A session whose loop is still open as the interpreter exits would be
reported unclosed by aiohttp when the interpreter destroys it. At exit,
the SDK now closes the session of a loop that is not running on that
loop, hands the close to a loop still running in another thread without
waiting for it, and marks the session of a loop closed without shutting
down closed, since its connections cannot be closed any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a Connections section to the README: what the client keeps open,
how close() and async with close it, what happens to a client that is
never closed, and the cases that need a close() call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SyncPermit._close_sessions now awaits the async client's close() on the
background loop, so close(), with, garbage collection and interpreter
exit close the sessions the sync client's calls reuse. The two
connection reuse tests that waited for the shared sessions now run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A copy of the sync client shared its client's thread and sessions, and
its close() stopped both, while the async copy's close() does nothing.
The sync copy's close() now does nothing as well, and only the client
that owns the sessions registers the closer its loop runs on close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
permit.sync.Permit inherited the async client's __aenter__ and
__aexit__, so async with on it would close it with a blocking call and
then fail awaiting None. It now raises a TypeError that points to with,
or to the async client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The async and the sync lifecycle tests each brought a local HTTP/1.1
server that counts connections. tests/keepalive_server.py now serves
both suites and the benchmark: per-path answers and delays, the requests
it read, opened and closed counts, waits on both, and a delayed answer
that a client closing its connection cuts short.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A child made by fork() inherited its parent's sessions, keyed by the
parent's event loops. Those loops look running but have no thread in
the child, so close() in the child, and the sync client's exit hook,
waited for them forever. The child now keeps them aside, untouched,
since their connections are the parent's, and resets the sessions'
lock; its own requests open sessions of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README's Connections section now covers the blocking client next to
the async one: its background thread, close() and with, cleanup of a
client never closed, the call it refuses on its own thread, and what
wait_for_sync() copies and forked children do with the connections.
CONTRIBUTING.md points to the keep-alive test server and the benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The consumer that test_typing_surface.py type-checks already covered the
blocking client's close() and with. It now covers the async client's
too: async with yields the client, close() must be awaited, and a plain
with on the async client stays an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under pydantic 1, import permit warns on stderr, which the test reads
for anything else the child or the parent reports. The sync lifecycle
scripts ignore that one warning the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client freed by the cyclic garbage collector, as one held by an
exception it raised is, was collected together with its open aiohttp
sessions, so aiohttp reported them unclosed before their close ran. On
CPython 3.13 and later the sync client's finalizer, which reached the
sessions through a view sharing the client's attribute dict, also let
the collector clear them while still in use: a dict's values live in
the object that owns it there, and the collector reaches them only
through that object.

A finalizer of each LoopSessions now holds its sessions apart from it,
so they stay reachable until closed. Once the LoopSessions is
collected, it closes the sessions of running loops on their loops, and
keeps the others until their loop shuts down or, for a closed loop,
the next request marks them closed. The sync client's own collection
finalizer and its view are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A small run, in an interpreter of its own, checks that the benchmark
still runs against the keep-alive test server and that each client
opens one connection for its sequential check() calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The close a collected LoopSessions hands to a running loop was a
coroutine created in the finalizer's thread. A loop that stopped and
closed before running it dropped it unawaited, and Python reported
that. The loop now creates the coroutine when it runs the close, and
keeps the task until it is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Async close() took every closable session out of the client, then
awaited a hand-off to each loop running in another thread. When such a
loop ended before running that close, close() raised the CancelledError
of the close that loop's asyncio.run() cancelled; when it stopped
first, close() waited forever. Either way the sessions after it were
neither closed nor tracked any more, so aiohttp reported them unclosed
once collected.

close() now closes the sessions side by side and raises the first error
once all have been tried. It waits for a loop in another thread only
while that loop runs, and a close that loop cancelled is not an error:
the loop closes the session as it shuts down its async generators. A
session still open afterwards, including when close() itself is
cancelled, is kept for the next close() or request. The close handed
to another loop is created on that loop, and closes the session before
its closer, so a loop shutting down meanwhile finds the session closed
rather than its closer running. A request never reuses a session that
is closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The permit fixture's teardown called close(), which waits for the
client's loop thread. If the re-entrancy guard regressed, the
re-entrancy tests deadlocked that thread, so after they failed the
teardown hung the whole run. The teardown now closes the client from a
daemon thread and fails after five seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
close() detached the client's loop thread before waiting for the calls
in flight. A call made meanwhile started a new thread, whose session
the old loop's close then closed under it, so the call failed with a
PermitConnectionError, and close() returned with a thread running. A
second close() made meanwhile returned at once, before the thread had
stopped.

While a close() stops the thread, a call from another thread now waits
for it to finish and then starts a new thread, and another close()
waits for it too. A call or close() made on the thread being stopped
raises RuntimeError, as one on the client's running thread does. A
child forked during a close() drops that close, as it drops the
running thread, so its first call does not wait for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The blocking client got a call's exception through the future that
asyncio.run_coroutine_threadsafe() fills, which copies it through
asyncio's own conversion. On Python 3.11 and 3.12 that replaces a
TimeoutError, such as a request timeout, with a new one that has
neither its traceback nor its cause, so the caller lost where it came
from.

The call's task now returns the exception it caught as its result,
and the calling thread raises that exception itself, with its
traceback and cause. The calling thread also drops its references to
the future and the exception as it raises, so a client whose call
raised is freed by reference counting, without a cycle through the
traceback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An async client driven on a loop closed with loop.close() alone, which
does not shut down its async generators, keeps that loop's keep-alive
connection open: nothing can close it on a closed loop. The next request
marks the session closed, and the garbage collector then frees the
connection, which Python reports with a ResourceWarning. Before
connections were reused, every request closed its own.

A test against the keep-alive server pins this, and the README says
when the warning shows up and that close() run on the loop before
loop.close() avoids it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs said that close() on a client yielded by wait_for_sync() does
nothing. That holds only with proxy_facts_via_pdp on. With it off, the
default, wait_for_sync() logs a warning and yields the client itself,
so close() on it closes the client's connections and, on the blocking
client, stops its thread. The README, both close() docstrings and
wait_for_sync()'s now say so, and a test pins the default case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_finalize_when_collected wrapped weakref.finalize with atexit turned
off and had one caller, while permit/utils/http_sessions.py sets the
same flag inline. The background loop now does it inline too, with
the same justification for the type: ignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every API object, HTTP client and enforcer builds a LoopSessions of its
own, and a Permit client replaces them all with its two. The class
docstring now says so, and that the replaced ones open no session and
are collected right away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/test_sync_lifecycle.py derived REPO_ROOT from the installed
permit package, while every other test module uses its own path. Its
subprocess scripts import tests.keepalive_server from REPO_ROOT, so
against a non-editable install they would have looked in
site-packages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
permit.sync.Permit.__aenter__ raises TypeError at runtime, but it was
annotated NoReturn, which type checkers accept anywhere, so mypy
--strict let `async with SyncPermit(...) as permit:` through and typed
permit as Any. It is now annotated to return None, which mypy reports
as an incompatible "async with". The type-check consumer pins the
mistake, and the runtime test of the refusal ignores it with a reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zeevmoney and others added 3 commits October 2, 2026 03:50
close() closed the session of the loop it runs on through that
session's closer generator. A session whose close failed is kept for
the next close(), but a closer that had raised is finished, so its
aclose() would no longer close that session. close() now closes the
session itself first, then its closer, as the close it hands to
another loop already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a free-threaded build, an object that another thread releases is
freed by the thread that created it, once that thread runs again. The
test of a client whose call raised now has the client's loop thread
run a callback, and polls the main thread, before it checks that the
client was freed, so it passes on free-threaded 3.14 as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The exit hook closed the session of a loop that is not running through
the session's closer alone. It now runs the same close as close() and
the hand-off to another loop: the session first, then its closer, so a
session kept after its closer failed is closed at exit as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 2, 2026

Copy link
Copy Markdown

PER-16344

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

Dependency Security Audit

Scanned: pyproject.toml dependencies + dev group, resolved at Python 3.10 (the current resolution, and the lowest versions the published specs permit under each pydantic major)

✅ No known vulnerabilities found.

Both the resolved dependency set and the lowest versions the published specs permit are clean at HIGH and CRITICAL.

permit/api/base.py imports both directly; they came in only through
aiohttp. The floors are the first releases with wheels for every
supported Python, so a floor install never builds them from source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zeevmoney and others added 5 commits October 2, 2026 06:39
Freeing the client at `del` can stop and close its background loop
before the test schedules its callback, so the test raised "Event loop
is closed" on a fast runner. It now treats a closed loop as the client
already freed, and still checks that the client is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Freeing the client at `del` can stop its background loop before the
test's callback runs, so waiting for that callback timed out on a slow
runner. The wake-up is now best effort, and the test checks the one
thing it is about: the client is freed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zeevmoney
zeevmoney added this pull request to stack #145 October 2, 2026 18:24

This branch has not been deployed

No deployments
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