Skip to content

breaking: the effect-stack lifecycle (#58): transports own their link, options, and sizing - #144

Open
JPHutchins wants to merge 15 commits into
screaming-goblinfrom
feat/58-effect-stack-lifecycle
Open

JPHutchins wants to merge 15 commits into
screaming-goblinfrom
feat/58-effect-stack-lifecycle

Conversation

@JPHutchins

@JPHutchins JPHutchins commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

Warning

LLM Disclosure

This PR was authored by claude-opus-5-5[1m] on behalf of @JPHutchins. @JPHutchins found the previous revision far more invasive than expected. She asked for a smaller diff on the idiom she settled on: transports are valid while closed, standard connect()/disconnect()/borrow() primitives with the connected()/borrowed() brackets encouraged, and SMPClient unable to control transport side effects. This revision is a fresh branch of individually green commits, force-pushed over the old one.

Note

Targets screaming-goblin, the breaking branch (#143, epic intercreate/smpmgr#103). Implements the design from #58. Closes #90. Closes #103. It supersedes the previous revision (a76b962) and its review.

A transport is constructed closed and owns its address, options, and sizing. SMPClient(transport) is a plain object: it can't open or close a link.

async with SMPSerialTransport("/dev/ttyACM0").connected() as transport:
    client = SMPClient(transport)
    await client.request(...)

async with SMPBLETransport(address).borrowed(bleak_client) as transport:  # the caller's link: never opened or closed here
    client = SMPClient(transport)
  • SMPTransport, what SMPClient sees, is only send, receive, send_and_receive, mtu, and max_unencoded_size. It has no lifecycle.
  • Primitives: connect() and disconnect() on every transport, plus borrow(resource) on serial, bleak, and bumble. The connected() and borrowed() brackets are the encouraged form. They add best-effort cleanup and release on cancellation.
  • Negotiation runs inside connect() and borrow(), and through a public negotiate(). It sends the MCUmgr params read only when the fragmentation strategy asks: Auto(), or GATT Unfragmented(). A timeout or error response warns and falls back.
  • The diff is smaller: src +1029/−601 and tests +880/−430, against +1424/−1209 and +2142/−1152 before.
Commits (each passes camas check on its own)
commit what
40c6d36 move the request/response exchange into a private smpclient._request; no API change
a353b5b breaking: transports take their address in the constructor and own their lifecycle; SMPClient loses connect/disconnect/address/async with
78c58ee breaking: per-transport *FragmentationStrategy unions sharing Auto/BufferSize/Unfragmented; conditional negotiate()
9d00fd4 breaking: pyserial kwargs → options=SerialOptions(...), locked to serial.Serial's signature by a test
9a5eb94 fix: serial open runs in asyncio.to_thread; a failed input flush no longer leaks the fd (pre-existing on main)
c8ccc02 serial borrow(port)/borrowed(port); the integration harness borrows its socket:// chardev instead of object.__setattr__
03306ea bleak borrow(client)/borrowed(client), polling is_connected; receive() no longer leaks tasks on cancel
0ecf44c bluez=BlueZClientArgs(adapter=...) for both the scan and the client (#103)
2bd7554 breaking: bumble bonded_devices/clear_bond/clear_bonds become module functions
bdc979e fix(bumble): tear down on a cancelled connect(); borrow() is all-or-nothing; return a borrowed link by unsubscribing only this transport
d35531e fix(ble): the link sum type carries the client, so a closed or returned transport raises SMPTransportDisconnected rather than AttributeError
5d58791 test(serial): lock SerialOptions to SerialBase, since serial.Serial.__init__ on Windows is *args, **kwargs
Breaking changes
before (main) after
SMPClient(transport, address, timeout_s), then async with client or client.connect() async with Transport(address, ...).connected() as t:, then SMPClient(t, timeout_s=...)
SMPClient.connect() / .disconnect() / .address removed
SMPTransport.connect(address, timeout_s) / .disconnect() / .initialize(buf_size) out of the Protocol. Concrete transports take connect_timeout_s in the constructor, connect() takes no arguments, and initialize() becomes negotiate()
SMPUDPTransport(mtu) + connect(address, timeout_s, port=1337) SMPUDPTransport(address, port=1337, *, mtu=, fragmentation_strategy=)
SMPBLETransport(winrt=) + connect(address, timeout_s) SMPBLETransport(address, *, winrt=, bluez=, fragmentation_strategy=); new borrow(client)
bumble use_connection(c) / borrowed_connection(t, c) t.borrow(c) / t.borrowed(c)
SMPBumbleTransport.bonded_devices() / .clear_bond() / .clear_bonds() module functions taking keystore= and host_address=
SMPSerialTransport(strategy, <pyserial kwargs>) + connect(port, timeout_s) SMPSerialTransport(port, strategy, *, options=SerialOptions(...))
smpclient.transport.serial.FragmentationStrategy / .Auto SerialFragmentationStrategy / smpclient.transport.Auto
SMPSerialRawTransport(mtu=384) SMPSerialRawTransport(port, fragmentation_strategy=Auto()); BufferSize(384) pins the old size
a pinned serial strategy still read the params, and warned on a mismatch a pinned strategy never reads them

Serial's BufferSize(buf_size, line_length) and BufferParams(line_length, line_buffers) are unchanged from main, as are the deprecated 7.1.0 params. Their removal is left to a follow-up.

Decisions, for review
  • Two BufferSize types. Serial keeps its own, which also carries a line_length. The other transports share smpclient.transport.BufferSize(buf_size), and the type checkers catch a mix-up. smpclient.transport.serial doesn't re-export the shared types; it exports only serial's own, defined in the encoded/unencoded/common submodules.
  • Ownership is a sum type. Serial uses _Owned | _Borrowed(port); its own Serial exists, closed, from construction. bleak uses _Closed | _Owned(client) | _Borrowed(client); its own client is created by connect(). I/O goes through a property that matches on it, and a transport with no link raises SMPTransportDisconnected. disconnect() returns a borrowed resource and never closes it; the acquirer releases. bumble keeps its existing state machine.
  • bleak borrow polls client.is_connected at 100 ms, and only while a receive or GATT wait is running. bleak accepts a disconnect callback only at construction, and the owner holds it. No watcher task outlives a call.
  • borrow() is all-or-nothing on every transport: if negotiate() fails or is cancelled, the resource is returned before re-raising.
  • A transport that only borrows still takes its address or port in the constructor. That keeps one constructor per transport; the value goes unused while the transport borrows.
  • UDP has no borrow(), because its client class is private.
  • Bleak transport options #90 (winrt=WinRTClientArgs(use_cached_services=True)) was already served by the winrt= kwarg on main; it is kept as is.
Verification
  • Each commit: camas check (ruff, pydoclint, mypy, pyright, tests) green.
  • CI on 5d58791: 28/28 (Linux x64/arm64, macOS, Windows × 3.10–3.14, extras, integration).
  • Tip: camas check 401 passed, 14 skipped; coverage 94.1% (floor 91%). camas matrix green on 3.10–3.14, and camas test_integration 229 passed, 101 skipped, the same as breaking: port to smp screaming-goblin (Frame[T] on msgspec) #137's baseline. Every socket-serial fixture, including serial recovery over all four framings, now runs through the public borrowed() API.
  • Each fix is pinned by a test confirmed to fail on the previous code:
    • the serial flush fd leak
    • the bleak task leak on cancel
    • the bumble cancelled connect()
    • the bumble borrow() negotiation failure
    • the bumble unsubscribe-everyone
    • the bleak AttributeError after a borrow returns
  • The SerialOptions lock test was confirmed to fail on a renamed field and on a changed default.

🤖 Generated with Claude Code

JPHutchins and others added 2 commits September 22, 2026 16:25
`SMPUDPTransport.max_unencoded_size` overrode the base implementation with
the MSS alone, so the MCUmgr parameters that `SMPClient` reads on connect
never reached the UDP transport: `initialize(buf_size)` stored the value and
nothing read it.

Zephyr's UDP SMP transport receives each request as a single datagram into
one MCUmgr buffer of `CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE` bytes, the value
advertised as `buf_size`. A request larger than that cannot be received. The
defaults (2048-byte buffer, 1500-byte MTU) hide this. A build that lowers the
buffer, e.g. to its 384-byte non-UDP default, does not.

The payload is now `min(MSS, buf_size)`. Before the params are known,
`buf_size or mtu` makes that the MSS, so nothing changes for a server that
does not advertise them.

Verified: `camas check` green; the new parametrized test fails on the 384-byte
case without the fix.

https://github.com/zephyrproject-rtos/zephyr/blob/70be2ff0b565a3313128f5577f51cfeb3ebcf602/subsys/mgmt/mcumgr/grp/os_mgmt/src/os_mgmt.c#L551-L554
https://github.com/zephyrproject-rtos/zephyr/blob/70be2ff0b565a3313128f5577f51cfeb3ebcf602/subsys/mgmt/mcumgr/transport/Kconfig#L33-L58

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix(udp): cap the payload at the server's advertised buffer
@JPHutchins JPHutchins mentioned this pull request Sep 22, 2026
5 tasks
"""

from smpclient.transport.serial.encoded import Auto as Auto
from smpclient.transport import Auto as Auto

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why re-export these?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing re-exports were needed becuase of the encoded/unencoded split

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

LLM Disclosure

This reply was authored by claude-opus-5-5[1m] on behalf of @JPHutchins. It answers this thread for the force-pushed revision of the PR.

These re-exports are gone in the new revision. smpclient/transport/serial/__init__.py no longer re-exports any shared smpclient.transport type. Auto is imported from smpclient.transport, as are the shared BufferSize and Unfragmented.

What it does export are serial's own types, defined in its encoded, unencoded, and common submodules. That is the same split-driven reason as before: serial's own BufferSize (unchanged from main), plus the new SerialOptions and SerialPort from common.

@JPHutchins

Copy link
Copy Markdown
Collaborator Author

Warning

LLM Disclosure

This review was authored by claude-opus-5-5[1m] on behalf of @JPHutchins. JP found the PR more invasive than expected and asked for three passes, in order: an advisor review, a full /code-review at xhigh, and an advisor review of that output. This comment posts the outcome.

Read it as a self-review. The implementer and the /code-review agent are the same model (claude-opus-5-5). The advisor passes ran on claude-fable-5-1.

Summary

  • 19 findings: 15 from /code-review, plus 4 that only the advisor raised. The happy path holds: CI is 28/28 and integration is 229 passed. The bugs are all on teardown, cancel and misuse paths.
  • 9 are real bugs to fix before merge. The worst four:
  • 4 decisions are yours:
    • the cost of the timeout policy
    • the sequence number the params read uses
    • the FragmentationStrategy naming
    • whether to split the PR
  • Nothing is changed yet. The naming change will churn the diff anyway, so fixes wait for your answers.
Bugs to fix (9), each with a proposed fix
# Where Bug Proposed fix
1 bumble/__init__.py _teardown_borrowed smp_characteristic.unsubscribe() with no subscriber drops every subscriber on the handle and writes 0 to the CCCD. Verified in bumble: Client.unsubscribe(subscriber=None) pops them all. A caller's own subscription, or an outer borrow(), goes silent, and the proxy entry leaks. This predates the PR but is now exposed, because borrow() makes a shared link the intended use. Keep the partial and pass it: unsubscribe(subscriber).
2 ble.py _subscribed finally await client.stop_notify() runs on every exit with no timeout and no disconnect guard. If the peer is gone but BlueZ still reports connected, exit hangs forever. On the owned path it's redundant, since disconnect() follows. Owned path: skip stop_notify. borrow(): guard it with _await_or_disconnect plus a timeout.
3 ble.py connected() finally A bare await client.disconnect() can raise, and the establishment error that mattered gets replaced. Make it best-effort, as the old _best_effort_disconnect was.
4 smpclient/__init__.py:61 from smpclient._request import request as _request rebinds the submodule attribute to the function. import smpclient._request as m then gives a function, and on 3.10 mock.patch("smpclient._request.…") patches the function instead of the module. Import the module: from smpclient import _request.
5 bumble/__init__.py pair() The removed state guard was doing real work. After exit, pair(force=True) deletes the stored bond (verified: pairing.pair deletes before pairing), then fails on the dead link. The same disconnected guard send() has.
6 serial/common.py open_serial reset_input_buffer() is inside the retry try. If it raises after open(), the retries hit "already open" and the file descriptor leaks. Move it after the loop, inside the try/finally.
7 serial/encoded.py BufferParams The first positional field changed from line_length to line_buffers, and both are int. Old code BufferParams(256) now silently means 256 line buffers. Keyword-only fields, e.g. @dataclass(frozen=True, kw_only=True).
8 smpclient/__init__.py SMPClient.__init__ timeout_s sits where address was, so un-migrated SMPClient(t, "COM3") constructs fine and fails later inside timeout(). Make timeout_s and sequence keyword-only.
9 bumble/__init__.py connected() except (Exception, asyncio.CancelledError) goes to logger.exception, so an ordinary cancel or outer timeout logs an ERROR traceback. A separate CancelledError arm, logged at debug, then re-raised.

Smaller items:

  • The encoded.py module docstring says "Auto assumes two 128-byte lines". That's a specific claim, and it's wrong for a non-default line_length. Make it vague.
  • Tests compare sizing NamedTuples by value, as in t._sizing == PINNED, which also passes for BufferSize(4). Use isinstance plus the field.
  • In _request.request(transport, request, …), the parameter shadows the function's own name.
  • bleak _watch_link logs a WARNING "Disconnected" when the owner closes a borrowed link on purpose. That level is wrong for that path.
  • Unfragmented lives in shared smpclient.transport, though only the GATT transports accept it.
  • connect_timeout_s bounds each step separately: 2× for serial, 3× for BLE. Serial's docstring reads as a single bound.
  • The harness's answering() re-borrows a serial port it hasn't flushed. That's safe only because the echo is fully consumed first; it needs a comment, and see B below.
Your decisions (4)

A. The cost of the timeout policy (/code-review #1). This is implemented as you specified it: "fall back on error, raise on timeout".

  • What it costs, per the review: a freshly reset device, whose USB CDC ACM is up before the SMP server is, now fails on entry. So does a UDP link that drops the one params datagram. Before, those carried on at the fallback size. The integration harness needed a probe-then-negotiate pattern for exactly this reason, and every caller talking to a just-reset device will need the same.
  • Options:
    1. Keep it.
    2. Raise the default connect_timeout_s.
    3. Add an explicit "negotiate, else fall back" variant.
    4. Retry the read inside the bracket.

B. The sequence number of the params read (review #2 and #12, advisor). The read hardcodes sequence 0, outside the client's injectable sequence space, which is the principle smp#71 set. wrapping_sequence() also starts at 0, so the params read and the client's first request are consecutive seq-0 frames.

  • Consequence: on a serial port that hasn't been flushed, a stale reply shows up as SMPValidationException, not the SMPBadSequence that _poll_until_answering tolerates. That's a plausible latent flake in the harness.
  • Proposal: connected()/borrow() take a sequence: Iterator[u8] (default: a fresh wrapping_sequence()), and the negotiation consumes its first value.

C. Naming (your call during review). Rename the concept to FragmentationStrategy, with one union per transport and the kwarg back to fragmentation_strategy. Proposed names:

Transport Union Options
console serial SerialFragmentationStrategy Auto | BufferSize | BufferParams
raw serial RawSerialFragmentationStrategy Auto | BufferSize
BLE (bleak, bumble) GATTFragmentationStrategy Auto | Unfragmented | BufferSize
UDP UDPFragmentationStrategy Auto | BufferSize

The prefixes are there because the console and raw transports share the serial package, so a bare FragmentationStrategy per module would clash.

D. Split the PR? Most of the churn is forced by the design:

  • the _request.py extraction (~220 lines)
  • the bumble closure restructure (~630 lines of churn, mostly moved rather than changed)
  • the ported tests (+2142, much of it new coverage)

What could come out, if you want a smaller PR:

Public surface you haven't approved yet
Surface Why it exists Question
SerialOptions Groups the 11 pyserial settings that were repeated across three signatures. Keep, or go back to flat kwargs?
SerialPort Protocol pyserial's serial_for_url ports, including the emulator's socket:// chardev, are a SerialBase, not a serial.Serial. Keep public?
open_serial in serial.common The retry-until-open bracket. The integration harness imports it from common. Re-export from smpclient.transport.serial, or keep it internal?
bumble bonded_devices / clear_bond / clear_bonds as module functions They need a keystore and a host address, never a connection. OK?
Connected / ConnectedBorrowed as SMPBumbleTransport(...) arguments The constructor wraps a live link. Callers should use the brackets. Make them private?
SMPBLETransport.__init__(client, characteristic, max_write, disconnected, …) Same reason. Same.
Pushed back on (1)

/code-review #13: _negotiate is copied into five transports. That's true, but it follows from the round-2 design, where the policy is one exhaustive match inside each transport. A shared negotiate_buf_size() -> int | None would move the policy back out of the transport. I recommend keeping the duplication.

Next: once A–D are answered, one fix commit for bugs 1–9 and the smaller items, then the naming rename, then re-gate and re-run integration.

🤖 Generated with Claude Code

JPHutchins and others added 12 commits September 23, 2026 13:53
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…request

A verbatim move with no behavior change: the TypeVars, the `success`/`error`
narrowers, `wrapping_sequence`, the validation diagnostics, and the body of
`SMPClient.request`, which becomes `_request.exchange(transport, request,
sequence, timeout_s)`. `SMPClient.request` delegates to it, and `smpclient`
re-exports every public name unchanged.

The next commit needs this: a transport reads the server's MCUmgr parameters
while it connects, before any `SMPClient` exists, so the exchange must sit below
the client. Review with `git show --color-moved`; 252 of the 284 changed lines
are moved.

`smpclient` imports `_request` as a module (`from smpclient import _request`),
so the package attribute stays the submodule. An `import ... as _request` of the
function would shadow it and break `mock.patch("smpclient._request.…")`. The
function is named `exchange` so that its `request` parameter doesn't shadow it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lient never opens a link

`SMPClient(transport, address, timeout_s)` becomes `SMPClient(transport, *, timeout_s,
sequence)`. `connect()`, `disconnect()`, `address`, `__aenter__`/`__aexit__` and
`_initialize()` are removed. The client only sends and receives over a transport that
is already open. It holds the `SMPTransport` Protocol, which no longer has `connect` or
`disconnect`, so it can't control the transport's side effects.

Each transport now takes its address in the constructor (#58), along with
`connect_timeout_s` and `sequence`. `connect()` and `disconnect()` take no arguments,
and their bodies are unchanged:

- serial: the old `connect` body becomes `_open()`; `connect()` is `_open()` then
  `negotiate()`
- UDP: `SMPUDPTransport(address, port=1337, *, mtu, ...)`. The port is a real
  parameter, the point of #58.
- bleak: `connect()` wraps `_connect(address, timeout_s)`, which is unchanged
- bumble: `connect()` is the old flow, reading the address from `self`.
  `use_connection` becomes `borrow`, and the module helper `borrowed_connection()`
  becomes the method `borrowed()`.

A private base, `_ConnectableTransport`, adds the encouraged bracket,
`async with transport.connected():`. It connects, yields the transport, and
disconnects best-effort. The primitives remain for lifetimes a lexical scope can't
express, e.g. a standing link held for an application's lifetime.

The MCUmgr parameters read moves out of `SMPClient._initialize` into the
transport's `negotiate()`, with the same warnings and the same fallback on an error
or a timeout (`_request.read_mcumgr_parameters`). `negotiate()` runs inside
`connect()` and `borrow()`, and it is public, for re-negotiating: the integration
harness uses it after a server boots, and a borrowed link can negotiate at all.
Behavior is unchanged here: the read is still unconditional. The next commit makes
it conditional on each transport's fragmentation strategy.

`connect()` is all-or-nothing on every transport: a failed or cancelled negotiation
closes the link it just opened.

Tests move to address-first constructors and argument-free `connect()`. A
`skip_negotiation` fixture answers the params read with `None`, so tests that drive
`connect()` over mocked I/O don't wait for a server. The integration harness enters
`transport.connected()` and re-negotiates after the echo wait, where it used to call
`client._initialize()`. Its skip for a UDP fixture on a non-default port is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e only when asked

Each transport now declares how it sizes SMP messages with its own
fragmentation strategy union, and reads the server's MCUmgr parameters
only when that strategy asks for them. A pinned strategy never issues
the read, so a server without the params command, like mcuboot serial
recovery, never sees it.

The shared vocabulary lives in `smpclient.transport`:

- `Auto`: read `buf_size` while connecting. On a timeout or an error
  response, warn and fall back to the transport's conservative default.
- `BufferSize(buf_size)`: a known server buffer; nothing is read.
- `Unfragmented`: GATT only. Like `Auto`, but one message per write,
  for a server built without `CONFIG_MCUMGR_TRANSPORT_BT_REASSEMBLY`.

The per-transport unions use the prefixed names:

- `SerialFragmentationStrategy = Auto | BufferSize | BufferParams`
  (serial's own `BufferSize(buf_size, line_length)` and `BufferParams`
  are unchanged from main)
- `RawSerialFragmentationStrategy = Auto | BufferSize`
- `UDPFragmentationStrategy = Auto | BufferSize`, always capped at the
  MSS
- `GATTFragmentationStrategy = Auto | Unfragmented | BufferSize`, shared
  by `SMPBLETransport` and `SMPBumbleTransport` through a `_GATTTransport`
  mixin

`SMPTransport.initialize()` and `_smp_server_transport_buffer_size` are
gone. Each transport's `negotiate()` matches its strategy exhaustively
and stores `_negotiated_buf_size`; `max_unencoded_size` is derived from
the strategy.

Breaking:

- `smpclient.transport.serial.FragmentationStrategy` is renamed
  `SerialFragmentationStrategy`.
- `Auto` moves to `smpclient.transport`, since every transport uses it.
- `SMPSerialRawTransport(port, mtu=384)` becomes
  `SMPSerialRawTransport(port, fragmentation_strategy=Auto())`; pin the
  old behavior with `BufferSize(384)`. Its `mtu` now reports
  `max_unencoded_size`, one whole message.
- The serial "pinned size exceeds the server's buffer" warnings are
  removed: a pinned strategy no longer reads the parameters it would
  compare against.

Tests: `tests/support.py` adds `advertise(buf_size)`, which patches the
params read, and `negotiated(transport, buf_size)`. Each transport tests
that a pinned strategy never reads, and how `Auto` and `Unfragmented` cap
the size. The integration raw transport defaults to `Auto()`, so the
suite exercises negotiation against the real fixtures (229 passed, 101
skipped, the same as before).

Refs #58

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The eleven pyserial keyword arguments are replaced by one
`options: SerialOptions = SerialOptions()` on `SMPSerialTransport`
(all three constructor overloads and the implementation) and on
`SMPSerialRawTransport`. The settings are declared once, in
`smpclient.transport.serial.common`, and exported from
`smpclient.transport.serial`; they are no longer repeated across the
four encoded signatures, the raw signature, and the base.

    SMPSerialTransport(port, baudrate=9600)
    SMPSerialTransport(port, options=SerialOptions(baudrate=9600))

`test_serial_options_lock_pyserial` locks the field names, their order,
and their defaults to `inspect.signature(serial.Serial)`. The one
deliberate difference is `baudrate`: 115200 here, 9600 in pyserial.
pyserial is effectively unmaintained, so drift isn't expected, but the
test fails loudly if it happens. A renamed field and a changed default
were each confirmed to fail the test.

Refs #58

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… flush fails

`_open()` now runs pyserial's blocking `open()` and
`reset_input_buffer()` in `asyncio.to_thread`, so a slow open (USB CDC
ACM still enumerating, for example) no longer stalls the event loop.

The leak, which is pre-existing on main: `reset_input_buffer()` was
inside the retry `try`. When the flush raised `SerialException` after
a successful `open()`, the loop called `open()` again on the open port.
pyserial refuses that with another `SerialException`, so the loop spun
until `connect_timeout_s` and raised `TimeoutError`, leaving the first
fd open. Only `open()` is retried now (`try/except/else`), and
`connect()` wraps `_open()` too in its all-or-nothing
`except (Exception, CancelledError): close()`. A failed flush, or a
cancellation during the open, closes the port and re-raises.

A cancellation that lands while the worker thread is still inside
`open()` cannot interrupt that thread. The best-effort `close()` runs
either way, and is a no-op if the open hadn't finished.

Tests: `test_connect_closes_the_port_when_the_flush_fails` fails on the
previous code. `test_connect_closes_the_port_when_cancelled_while_negotiating`
covers the cancel path.

Refs #58

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…cket chardev

`_SerialTransportBase` gains the same borrow primitives the GATT
transports have:

- `await t.borrow(port)` adopts a caller's open `SerialPort`, clears the
  framing state, then runs `negotiate()`. It is all-or-nothing: if
  negotiation raises or is cancelled, the transport reverts to its own
  port.
- `async with t.borrowed(port):` wraps borrow and disconnect in a
  bracket.
- `disconnect()` returns a borrowed port without closing it; the
  acquirer releases. It still closes the transport's own `Serial`.

`SerialPort` is the Protocol for the four members the transports use:
`port`, `out_waiting`, `write`, and `read_all`. `serial.Serial`
satisfies it, and so does a `serial_for_url` port that reports
`out_waiting`. Both it and `SerialOptions` are exported from
`smpclient.transport.serial`.

Which port is live is a sum type, `_Link = _Owned | _Borrowed(port)`.
`_conn` becomes a property that matches on it, and `_serial` is the
transport's own `Serial`, still constructed closed in `__init__`. The
unit tests' `t._conn.<attr> = MagicMock(...)` assignments still land on
the owned mock, so they keep working unchanged.

Integration harness: the `QemuSocketSerialTransport` and
`QemuSocketSerialRawTransport` subclasses are gone. They overrode `_open`
and replaced the `Final` `_conn` with `object.__setattr__`. Now
`socket_link(transport, url)` opens the emulator's `socket://` chardev
(paced for the raw transport), lends it with `transport.borrowed()`,
and closes it on exit, so the suite drives the real public API.
`ConnectedServer` carries its link as an `AsyncExitStack`, and
`reboot_into_recovery` releases it with `link.aclose()` before opening
the recovery link it is handed. Integration: 229 passed, 101 skipped,
the same as before, with every socket fixture going through `borrowed()`.

Refs #58

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`SMPBLETransport` gains the borrow primitives the other transports
have:

- `await t.borrow(client)` adopts a caller's connected `BleakClient`,
  finds the SMP characteristic, sizes writes to the link, subscribes,
  and then runs `negotiate()`. It is all-or-nothing: on failure or
  cancellation the transport returns the client and re-raises.
- `async with t.borrowed(client):` wraps borrow and disconnect in a
  bracket.
- `disconnect()` on a borrowed client unsubscribes and never
  disconnects it; the acquirer releases. The `stop_notify` is bounded
  by `connect_timeout_s`, and a failure is logged rather than raised, so
  returning the client can't hang or mask the caller's error when the
  owner has already dropped the link.

The part of `_connect()` after the link comes up is now `_start_smp()`,
shared by connect and borrow, and it clears the receive buffer.
Ownership is a sum type, `_Link = _Owned | _Borrowed(client)`.
`_active_client` matches on it; `_client` stays the transport's own
client, so the unit tests that assign it keep working.

Disconnect detection: bleak takes `disconnected_callback` only when
the client is constructed, and the owner holds it. So
`_until_disconnected()` waits on the transport's event when it owns the
client, and polls `client.is_connected` every 100 ms when it borrows
one. The poll runs only inside a receive or GATT wait. There is no
watcher task, so nothing outlives the primitive that started it.

`_notify_or_disconnect` now reaps its two sub-tasks in a `finally`, like
`_await_or_disconnect`. Before, cancelling a waiting `receive()` leaked
both tasks; with a borrowed client, that would leave a poll loop
running for as long as the owner's link stayed up. The old
`except CancelledError: pass` around the reaping `gather` also
swallowed a cancellation of the waiter itself; a positional
`gather(..., return_exceptions=True)` doesn't.
`test_borrowed_receive_leaves_no_task_polling_when_cancelled` fails
without the `finally`.

Refs #58

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`SMPBLETransport(address, bluez=BlueZClientArgs(adapter="hci1"))`
scans for the device and connects to it on that adapter, instead of
bleak's default. The options are bleak's own type, so the
`BlueZClientArgs` passes to `BleakClient`, and its keys, a subset of
`BlueZScannerArgs`, pass to `find_device_by_address` and
`find_device_by_name`. `SMPBLETransport.scan()` takes
`bluez: BlueZScannerArgs` too. Both default to `{}`, which is bleak's
default adapter, so behavior is unchanged.

This sits beside the existing `winrt=WinRTClientArgs(...)`, which
already carries #90's `use_cached_services`. bleak is pinned `>=3.0.2`,
and the BlueZ adapter args date from 3.0.

Closes #103

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`SMPBumbleTransport.bonded_devices()`, `.clear_bond(address)`, and
`.clear_bonds()` become module functions in `smpclient.transport.bumble`:

    await bonded_devices(keystore=..., host_address=...)
    await clear_bond(address, keystore=..., host_address=...)
    await clear_bonds(keystore=..., host_address=...)

They only ever read the transport's `keystore` and `host_address`, the
keystore namespace, and never its link. So listing or clearing bonds
no longer means building a transport for a device address you don't
plan to connect to. The defaults match the transport's, `Tempfile()`
and `DEFAULT_HOST_ADDRESS`, so a call with none of the options sees the
same bonds a default transport writes. The private
`_standalone_keystore()` helper is gone.

The functions had no tests; `test_bond_functions_manage_the_hosts_bonds`
seeds a keystore, then covers list, per-host isolation, clearing one
bond, and clearing all.

Refs #58

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ibe only ourselves

Three lifecycle gaps from the #144 review:

- A cancelled `connect()` leaked its partial state. The teardown arm was
  `except Exception`, which `CancelledError` bypasses, so a cancel mid
  `device.connect()` left the state at `Connecting` with the HCI
  transport open. Every later `connect()` then raised "called while in
  state Connecting". A `CancelledError` arm now tears down and
  re-raises, logged at debug: a cancel is the caller's decision, not an
  error. This is pre-existing on main.
- `borrow()` was not all-or-nothing. If `negotiate()` raised or was
  cancelled, the transport stayed `ConnectedBorrowed`, subscribed, with
  its disconnection listener attached. It now returns the connection
  (`disconnect()` → `_teardown_borrowed`) and re-raises, like serial and
  bleak `borrow()`.
- Returning a borrowed connection called `smp_characteristic.unsubscribe()`
  with no subscriber. bumble reads that as "drop every subscriber" and
  writes the CCCD to zero, cutting off the owner's own notifications on
  the shared characteristic. It now passes `self._on_notification`.
  bumble keys subscriber proxies by the subscriber, and a bound method
  compares equal each time it's looked up, so only this transport's
  proxy is removed, and the CCCD is cleared only if no subscriber is
  left. The owned teardown still unsubscribes everything; it owns the
  whole link.

Each new test fails on the previous code.

Refs #58

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ises SMPTransportDisconnected

`_Link` is now `_Closed | _Owned(client) | _Borrowed(client)`, and the
separate `_client` attribute is gone. `connect()` creates
`_Owned(BleakClient(...))`; `borrow()` makes `_Borrowed(client)`; and
`disconnect()` ends in `_Closed()` from any state, so it stays
idempotent.

This closes a gap that was also on `main`. Once a transport that only
ever borrowed had returned its client, `disconnect()` switched back to
the old `_Owned()` marker, and `_active_client` read a `self._client`
that only `connect()` assigns. `send()`/`receive()` raised
`AttributeError: _client` instead of `SMPTransportDisconnected`; the
same was true on `main` for a transport that never connected. With the
client inside the variant, "no client" is its own case:

- `_active_client` raises `SMPTransportDisconnected` on `_Closed`.
- `_until_disconnected` returns at once on `_Closed`.
- `_best_effort_disconnect` delegates to `disconnect()`, dropping its
  defensive `getattr`.
- `_set_disconnected_event` still rejects a callback from a client
  other than the owned one. After our own `disconnect()` the link is
  `_Closed`, so bleak's callback for that disconnect is accepted.

Tests inject `t._link = _Owned(client)`, or read the owned client with
`_owned_client(t)`, instead of assigning `t._client`. Four sizing tests
dropped a client assignment they never used.
`test_a_returned_borrow_raises_disconnected` fails on the previous code
with the `AttributeError`, and `test_disconnect` now also checks
idempotence and a send after close.

Refs #58

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@JPHutchins
JPHutchins force-pushed the feat/58-effect-stack-lifecycle branch from a76b962 to d35531e Compare September 23, 2026 22:11
@JPHutchins

Copy link
Copy Markdown
Collaborator Author

Warning

LLM Disclosure

This comment was authored by claude-opus-5-5[1m] on behalf of @JPHutchins. It marks the force-push of the less invasive revision she asked for.

Force-pushed the new revision: a76b962d35531e. It is a fresh branch of 11 commits, each passing camas check on its own, and the description is rewritten for it. The earlier review applies to a76b962.

src tests
a76b962 +1424 / −1209 +2142 / −1152
d35531e +1029 / −601 +880 / −430

At the tip:

  • camas check: 401 passed, 14 skipped; coverage 94.1%.
  • camas matrix: green on 3.10–3.14.
  • camas test_integration: 229 passed, 101 skipped, the same as the baseline.

…al's settings everywhere

The lock test read `inspect.signature(serial.Serial)`, which only works
where `serial.Serial` inherits `SerialBase.__init__`: POSIX. On Windows,
`serial.Serial` is `serialwin32.Serial`, whose
`__init__(self, *args, **kwargs)` sets up the overlapped handles and
forwards to `SerialBase.__init__`. The signature there reads `('args',)`,
which failed every Windows job on #144.

`SerialBase` in `serial.serialutil` declares the settings on every
platform, so the test reads its signature, and first asserts that
`serial.Serial` subclasses it. The transport already passes
`**options._asdict()` through `serial.Serial` to `SerialBase`.

Refs #58

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@JPHutchins JPHutchins left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going the right direction. Overall poor code quality, needs some review and revision. Better typing, less mutation, etc etc.

Comment on lines +180 to +181
sequence: The SMP sequence space the MCUmgr parameters read draws from;
defaults to `wrapping_sequence()`.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yu say it defaults to wrapping_sequence, yet you default it to None in the sig

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The correct approach is for it to be structural (type level) not "prose".

Comment on lines +184 to +186
self._fragmentation_strategy = fragmentation_strategy
self._connect_timeout_s = connect_timeout_s
self._sequence = _request.wrapping_sequence() if sequence is None else sequence

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these final?



async def bonded_devices(
*, keystore: KeystoreStrategy = Tempfile(), host_address: Address = DEFAULT_HOST_ADDRESS

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why kwargs only?

Comment on lines +130 to +131
self._connect_timeout_s = connect_timeout_s
self._sequence = _request.wrapping_sequence() if sequence is None else sequence

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these final?

Comment on lines +143 to +149
SerialFragmentationStrategy: TypeAlias = Auto | BufferSize | BufferParams
"""How `SMPSerialTransport` sizes SMP messages: `Auto`, `BufferSize`, or `BufferParams`.

With `Auto`, connecting reads the server's `buf_size` (the decoded reassembly buffer) and
the transport sends messages up to `buf_size - 4`, filling that buffer; until the parameters
are read, or if the server doesn't provide them, it assumes a conservative line budget.
"""

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

llm doc slop - restates the code and other doc strings

Comment on lines -65 to -71
def initialize(self, smp_server_transport_buffer_size: int) -> None: # pragma: no cover
"""Initialize the `SMPTransport` with the server transport buffer size.

Args:
smp_server_transport_buffer_size: The SMP server transport buffer size, in 8-bit bytes.
"""
self._smp_server_transport_buffer_size = smp_server_transport_buffer_size

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't negotiate required now?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errr, I guess not, since client doesn't call it.

Comment on lines +123 to +129
async def connect(self) -> None: # pragma: no cover
"""Open the link, then `negotiate()`."""
...

async def disconnect(self) -> None: # pragma: no cover
"""Close the link."""
...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add note to not use them; dangerous loss of side effect control

Comment on lines +134 to +135
winrt: WinRT backend arguments, e.g. `use_cached_services`.
bluez: BlueZ backend arguments, e.g. the `adapter` to scan and connect with.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're telling me that we accpet both? On macos too? At the same time. FUCKING GARBAGE. This is a sum type.

self._address: Final = address
self._fragmentation_strategy = fragmentation_strategy
self._connect_timeout_s = connect_timeout_s
self._sequence = _request.wrapping_sequence() if sequence is None else sequence

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final?

Comment thread src/smpclient/__init__.py
return summary, details


class SMPClient:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be generic over the transport, so that users can access the transport in a type safe way.

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