Skip to content

feat(052): runtime-neutral connector I/O - #248

Merged
lxsaah merged 19 commits into
mainfrom
feat/platform-independent-connectors
Sep 4, 2026
Merged

feat(052): runtime-neutral connector I/O #248
lxsaah merged 19 commits into
mainfrom
feat/platform-independent-connectors

Conversation

@lxsaah

@lxsaah lxsaah commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Implements wave A of design 052 — runtime-neutral connectors: the layer that lets an adapter own sockets, clocks and name resolution while a connector owns framing and protocol. A new runtime then costs one adapter crate and zero connector edits.

This PR is additive. Nothing consumes the new code on the live paths yet — the existing tokio_* / embassy_* connector modules are untouched and still own every production path. Migrating them (and deleting ~4 130 lines) is wave B.

What lands

Crate Change
aimdb-core session::ioByteStream, StreamDialer, StreamListener, Datagram, DatagramBinder, Delay, Framer; FramedConnection + FramingDialer/FramingListener; OneShot<T>
aimdb-tokio-adapter net feature — TokioNet::tcp/listen/udp, TokioDelay. Every future a plain async fn, no unsafe
aimdb-embassy-adapter net feature — EmbassyNet::tcp/listen::<N>/udp, EmbassyUart, EmbassyDelay. All force-Send for these paths lives here
aimdb-knx-connector neutral::connection_task — one task for both runtimes; TunnelIo::send gains + Send; embassy-sync/embassy-futures on std
aimdb-serial-connector neutral — the crate reduced to a COBS Framer; byte sources come from the adapters
aimdb-tcp-connector tests/neutral_pool.rs — the accept-pool proof
aimdb-mqtt-connector breaking: TlsOptions::new requires a Send RNG; TlsSlot becomes OneShot. Crate now carries zero unsafe impls (was two)

Design decisions worth review

The Send bound sits on each trait's return type, not at the use site. Generic connector code must produce Send futures at the boxing boundary, and return-type notation is still experimental on the pinned 1.98 toolchain. A std impl writes a plain async fn and the compiler discharges it; an Embassy impl returns the adapter's force-Send newtype. aimdb-core/src/session/io.rs carries a compile-time assertion, so dropping a bound fails in core rather than three crates away.

The Embassy listener stores one pending accept per slot rather than rebuilding them. TcpSocket::accept is a synchronous listen() plus a bare poll_fn, so dropping the future does not un-listen the socket — but re-entering accept() on a listening socket is an error, and the abort() that makes it re-enterable is what drops the LISTEN. tests/neutral_pool.rs holds both halves to real sockets, with a rebuild-and-cancel pool as a negative control that loses a SYN arriving between accepts.

Datagram::local_addr is part of the contract. Without it, unifying the KNX task would have silently downgraded every Tokio deployment to the NAT-style 0.0.0.0:0 HPAI that some gateways reject. unified_task_advertises_the_real_local_endpoint reads the CONNECT_REQUEST off the wire and asserts the real bound address.

embassy-sync and embassy-futures are executor-independent despite the names — neither pulls an executor, and embassy-futures has no dependencies at all. Both now back the KNX task on std. CriticalSectionRawMutex is the only Sync raw mutex embassy-sync offers, and it is a link-time obligation on std, so tokio-runtime enables critical-section/std itself and tests/shared_channel_on_std.rs proves the binary links.

Deviations from the design doc

  • EmbassyUart::new(rx, tx), not ::split — the caller has already split the UART; this joins the halves.
  • Acceptance criterion 3 relaxed: aimdb-serial-connector's tokio-runtime now depends on aimdb-tokio-adapter, so its byte source comes from the adapter on both runtimes instead of being duplicated. Recorded in that crate's CHANGELOG as an explicit reversal.
  • The TlsOptions gate is on the connector's embassy-tls path, not the demo — see below.

Incidental fixes

  • 23 rustdoc errors across six crates (public docs linking to private or feature-gated items), plus RUSTDOCFLAGS=-D warnings on make doc, which CI already runs — so this cannot regress silently.
  • make examples was broken and never run in CI. stm32-metapac 21 renamed the RCC enum variants, so all five embedded examples failed to compile. Fixed, and all five now build for thumbv8m.main-none-eabihf — their actual board architecture — instead of thumbv7em. thumbv8m.main is pinned in rust-toolchain.toml and the devcontainer.
  • Clippy never linted aimdb-core with connector-session; it does now.

Verification

Every commit was verified in isolation: unit + integration tests, clippy -D warnings on each feature configuration, thumbv7em cross-compilation, rustdoc, and cargo fmt --check. make examples passes end to end. Acceptance criterion 1 holds — core cross-compiles with the new traits and still contains zero unsafe.

The three remaining unsafe impls in connector crates are all in aimdb-tcp-connector/src/embassy_transport.rs, the module wave B deletes.

Not yet run: a full make check across the branch. Per-crate runs can miss feature-unification effects, and this branch changed feature graphs in four crates — CI is the check.

@lxsaah lxsaah changed the title feat(052): runtime-neutral connector I/O — wave A (additive) feat(052): runtime-neutral connector I/O Sep 3, 2026
Six fixes from review of the wave-A branch. All are in the new code; the
existing tokio_*/embassy_* connector paths are untouched.

embassy-adapter: restore the cancel and yield guards lost in the port

The neutral `net` module reproduced `embassy_transport.rs`'s happy paths
but not the edge cases that module's guards and comments exist for.

- `EmbassyTcpDialer::connect` took the socket out of its slot and returned
  it only on connect's `Err` branch. A dial cancelled mid-`connect()` (a
  select timeout, a task shutdown) dropped the socket with the future,
  leaving the slot permanently empty and every later dial failing with a
  bare `TransportError::Io`. Both the dial and accept paths now hold the
  socket in a `SlotReturn` drop guard, as the sibling module does.
- Neither path yielded before reporting a synchronously-failing attempt.
  Core's `serve` logs an accept error and re-enters `accept()` with no
  backoff, so a port-0 `InvalidPort` spun a non-yielding loop and starved
  the single-core cooperative executor — a config typo hanging the device
  rather than warn-looping. `yield_now().await` restored on both, with the
  comment naming the case.

knx-connector: a rebind that cannot learn its address falls back to NAT

`engine` outlives the bind loop and `set_local_endpoint` was called only
inside `if let Some(..) = local_addr()`. On Embassy `local_addr()` is
`None` whenever the stack has no address — DHCP renewal, link flap, which
is what causes the rebind — so the next CONNECT_REQUEST re-advertised the
previous cycle's port, the gateway replied to a dead port and the tunnel
could never re-establish. Strictly worse than the `0.0.0.0:0` the explicit
HPAI exists to avoid, so the `None` case is now explicit and falls back.
Covered by a regression test that fails against the previous code.

knx-connector: restore the select fairness tokio gave us

`embassy_futures::select3` polls in declaration order, where the
`tokio::select!` it replaced chose among ready arms at random. Sustained
inbound traffic meant the command arm was never reached and outbound
`GroupWrite`s stalled until the channel dropped them. The two contended
arms now swap each pass.

knx-connector: leave the critical-section impl to the final binary

`tokio-runtime` enabled `critical-section/std`. That impl is registered by
symbol name and is global to the binary, so per critical-section's own docs
only the final binary may pick one; a downstream binary that also linked an
impl got duplicate symbols with no way to opt out. The choice moves to an
opt-in `critical-section-std-impl` feature, with a dev-dependency covering
this crate's own test binaries.

Makefile: actually check the new module's docs

`RUSTDOCFLAGS=-D warnings` was added and `net` reached the tokio adapter's
`cargo doc` line but not the embassy adapter's, leaving the largest new file
in the crate unchecked — and failing with three errors when run. Feature
added and the broken doc links fixed, so `make doc` covers it.

Verified: `make doc` and `make examples` end to end; clippy `-D warnings`
on each touched feature configuration including `thumbv7em` cross-compiles;
knx (37 lib + 35 integration), embassy-adapter `alloc,net`, tokio-adapter
`net`, serial, and tcp `neutral_pool` against two real embassy stacks;
`cargo fmt --check`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019H6kuPb5RKWRX1irYZwHrZ
`neutral` named these modules by contrast with the per-runtime modules they
displace, not by what they contain — both declarations said as much ("the
runtime-independent replacement for the two client modules"). Wave B deletes
that contrast partner, after which `neutral` distinguishes the module from
nothing: everything left in each crate is runtime-neutral, as `tunnel.rs`
already was without needing the word in its name.

The decay had started: in `aimdb-knx-connector/src/lib.rs`, `pub mod neutral`
sat under a `// Platform-specific implementations` header saying the opposite
of what it is.

Renaming now because `pub mod neutral` is public API. Wave A is additive and
unreleased, so this is the last moment the change is free rather than
breaking.

  aimdb-knx-connector/src/neutral.rs    -> src/client.rs
  aimdb-serial-connector/src/neutral.rs -> src/framer.rs
  aimdb-tcp-connector/tests/neutral_pool.rs   -> tests/accept_pool.rs
  aimdb-serial-connector/tests/neutral_framed.rs -> tests/framed.rs
  aimdb-embassy-adapter/tests/neutral_udp.rs  -> tests/udp.rs

This also settles on the convention core and the adapters already used for
the same layer — `session::io` and `net`, both named for their contents.

Pure rename: no content changes beyond the module declarations, one import,
the misfiled header comment, the Makefile's `--test` target, and the
changelog and design-doc pointers into these paths. Prose uses of the word
where it is a genuine adjective ("runtime-neutral", "role-neutral") are
untouched.

Verified: clippy `-D warnings` on both connectors for tokio and the
`thumbv7em` embassy cross-compile; knx (37 lib + 35 integration), serial,
`accept_pool` over two real embassy stacks, `udp`; `make doc`;
`cargo fmt --check`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019H6kuPb5RKWRX1irYZwHrZ
`framer.rs` next to `framing.rs` was a confusable pair for one subject: the
COBS codec and that codec behind core's `Framer` trait. They are now one
module, with the module doc naming the two layers and why only the second is
feature-gated.

The gate moves from the module declaration onto the items that need it —
`CobsFramer`, the chunk sizes and the `FramedConnection` aliases name
`aimdb_core::session`, which core gates on `connector-session`. `encode_frame`
and `FrameAccumulator` never needed it and stay ungated, so the codec still
builds with no runtime feature at all.

Public paths change from `framer::*` to `framing::*`; the items keep their
names.

Verified: clippy `-D warnings` for tokio, the `thumbv7em` embassy
cross-compile (which type-checks `_same_framed_connection_serves_the_uart`,
the assertion that the two runtime paths have not diverged), and no runtime
feature at all; serial tests; rustdoc on both runtimes; `make doc`;
`cargo fmt --check`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019H6kuPb5RKWRX1irYZwHrZ
@lxsaah
lxsaah merged commit 30aad07 into main Sep 4, 2026
12 checks passed
@lxsaah
lxsaah deleted the feat/platform-independent-connectors branch September 4, 2026 19:52
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