Skip to content

The two FFI doors, and a log destination in place of the host's subscriber - #11

Closed
lxsaah wants to merge 17 commits into
mainfrom
weather-station-cpp
Closed

lxsaah wants to merge 17 commits into
mainfrom
weather-station-cpp

Conversation

@lxsaah

@lxsaah lxsaah commented Aug 28, 2026 •

Copy link
Copy Markdown
Member

Brings both FFI spikes onto main: the pyo3 door, the C ABI door, the review fixes that came out of them, and the design-050 migration that stops either one installing its host application's global tracing subscriber.

Dependency on aimdb — resolved

This needed aimdb-core's log feature, which was not on aimdb's main when this PR was opened. It is now, as aimdb#237 (878486b). .github/workflows/ci.yml checks out aimdb-dev/aimdb with no ref:, so CI picks it up automatically — the first run here predated the merge and failed with aimdb-core does not have that feature; it has been re-run against the merged base.

What design 050 changes, and why

Both doors had arrived at the same unhappy answer to "how does a non-Rust application receive aimdb's reporting?": add tracing-subscriber, write a Layer, and install the host process's global subscriber on its behalf. That is the one thing a library loaded into somebody else's process must not do — and for the Python door it was the same trespass that removing init_tracing was meant to end, committed one layer further down where it was harder to see.

aimdb-core grew a second, optional destination for its log_* facade, so a door can hand aimdb a destination it owns instead. Both doors now install a log::Log; neither carries tracing-subscriber any more.

It deletes two real defects in the C++ header

A tracing::Layer has nowhere to put a void *user_data, so weather_station.hpp kept a detail::SinkHolder static beside the callback. Everything wrong with it followed from that:

  • it was assigned on every call, before asking whether the install would be accepted — last-wins over a first-wins C layer, so a second init_logging replaced the first caller's sink and returned false to say it had not;
  • it was assigned from the calling thread while the trampoline read it from aimdb's runtime thread.

A log::Log impl is the context, so the static has no reason to exist. ws_init_logging forwards to log::set_boxed_logger, which makes the first-wins decision once and in Rust for every binding; the header allocates the (sink, user_data) pair the C ABI cannot carry in one void *, hands it over as user_data, and releases it if the install is refused.

Measured by compiling the current spike against the previous header: 86,890 events went to the sink that had just been told it was not installed, and the first caller's sink stopped receiving entirely — taking the reentrancy and target-routing rounds down with it. Against this header, zero. Two new spike rounds assert both halves, and the second is written so it cannot pass vacuously.

The trampoline itself stays. It does two jobs — carry user_data, and catch a C++ exception that would otherwise unwind into Rust — and only the first is obsolete. It now holds no state.

Feature layout

weather-station gains a log feature mirroring its tracing one. This is required rather than cosmetic: the log_* macros are #[macro_export]ed, so #[cfg(feature = "log")] inside them resolves against the expanding crate. Without it, that crate's call sites are silently unrouted — and, since Cargo's check-cfg, also warn once per call site.

init_tracing and tracing-subscriber move out of tokio-runtime into a new init-tracing feature that default carries. Station binaries and templates are unchanged; a library build can now have the Tokio station without a subscriber, which is what makes "no tracing-subscriber in cargo tree" reachable at all.

One judgement call worth a reviewer's eye

weather-station-py takes weather-station with default-features = false. On its own branch that was harmless, because a TLS backend was not yet selectable — rumqttc named use-native-tls directly. Merged with CR-11's backend selection, it means no backend is selected at all, and mqtts:// would have failed at run time with nothing failing at build time. A clean auto-merge of two correct sides producing a broken result.

Resolved by naming rustls explicitly, for the reason CR-11 itself gives and which describes this crate exactly — a build that must not hand its host process a second OpenSSL, "an FFI layer shipped as a shared library, above all". The C door made the same call. cargo tree -p weather-station-py links no OpenSSL; the one openssl-probe in the graph is rustls-native-certs' cert-store locator.

native-tls is a one-word change if the wheel should keep it.

Known regression, named rather than left to be discovered

Filtering is coarser on both doors. EnvFilter came free with tracing-subscriber and went with it; span, field and regex directives are gone. What remains is level and target=level, comma-separated, longest matching prefix first.

It costs little at the Python door, where filter is only the floor that keeps events from acquiring the GIL and Python's own per-logger levels do the fine-grained work above it. It costs more at the C door, where it is the only filter there is. Both READMEs say so. No Rust consumer is affected — they keep tracing and EnvFilter exactly as today.

Verification

Re-run locally against aimdb main at 878486b (not the feature branch), with a live broker:

  • make clippy, make test, make fmt-check, make lockfile — clean
  • make spike — all checks passed, including "the module does not install a global tracing subscriber" (now true by construction rather than by convention) and "one subsystem can be silenced without silencing the others"
  • make spike-cpp — all checks passed, including the two new rounds above, and the sink still reports aimdb_core::builder / aimdb_core::session::pump targets

cargo tree for both doors contains neither tracing nor tracing-subscriber.

Note on the branch

weather-station-py is fully contained in this branch and can be deleted once this merges.

lxsaah and others added 17 commits August 21, 2026 20:34
The pendant of weather-station-py, run for the language where the boundary
is a C ABI: no interpreter mediates and the compiler stops helping at the
extern "C". Built to find out what an FFI layer needs from the station
crates before they reach a registry.

Two artifacts rather than one, because Rust cannot export C++ — no class,
no std::string, no std::function has an ABI stable even between two builds
of the same compiler. src/lib.rs exports fourteen ws_* symbols; the C++
RAII and exception layer is header-only and compiled by the consumer's own
toolchain. That is the same conclusion weather-station-py reached from the
other end.

The Python door's crate fixes carried, exactly as its README predicted the
C ABI layer would inherit rather than re-solve them: shutdown(&self) is
what lets ws_station_close take a const pointer, and is_closed() reading an
atomic is what survives a log sink reentering the getters from aimdb's
runtime thread mid-shutdown.

What C adds: nothing may unwind across the boundary (catch_unwind at every
entry point, and a noexcept trampoline in the header for the other
direction), every argument is hostile (NULL and non-UTF-8 paths are
refused, not mangled), and ownership has to be prose because free is the
one entry point that is not thread-safe.

What it found: after fork(), the child is told the station is open, its
publish returns success, and the reading is dropped. Measured against a
live broker. No fix here — pthread_atfork is process-global, and
registering it on the application's behalf is the trespass this crate's
predecessor already established a rule against.

Findings in README.md; the twelve requirements they imply for aimdb core
in REQUIREMENTS.md, four of them release-gating.

Run it with `make spike-cpp` (needs mosquitto and a C++17 compiler).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRVG2B9A8hfX8JMiXsW6nD
Fixes both, in patches/ against the sibling aimdb checkout, and records
what tracing them turned up.

Neither needed a new idea. The unbounded spin in AimDbHandle::new becomes
a channel and one blocking_recv, which is the shape new_from_builder has
used since it was written — a runtime that fails to start is now
AttachFailed instead of a caller that never returns. The 10 ms poll in
detach_internal becomes recv_timeout on a channel the helper thread sends
through.

The poll was the floor under every shutdown in every language binding:
close() drops from 10 ms to 0-1 ms across four spike rounds. aimdb-sync
no longer busy-waits anywhere.

Two corrections and one escalation:

CR-2's claim that the spin could hang ws_station_open_profile was wrong.
There are two attach() entry points and weather-station takes the other
one; the spin was never on this door's path.

The second constructor is the original design-007 entry point, superseded
once AimDbBuilder::build() went async and had to run inside the runtime
thread. It has no in-tree callers left, which is why nothing exercised the
spin. Worth keeping now that it is fixed, but it needs a test.

CR-6 is not a long-standing API shape to reconsider. aimdb-sync's own
changelog files it under Changed (breaking) for the unreleased 0.6.0:
SyncConsumer's methods took &self and it was Clone + Sync in 0.5.0. The
compiler confirms the loss of Sync and root-causes it to Box<dyn
BufferReader<T> + Send> in aimdb-core's Reader<T>. That makes it the one
finding so far that is release-gating rather than patchable later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRVG2B9A8hfX8JMiXsW6nD
Extends the busy-wait patch. The channel that replaced the spin now carries
Result<T, String> rather than a bare handle, so the three outcomes have
three arms: the value, the thread's own reason for failing, and a dropped
sender — the last being the one that used to be a hang.

The Err arm is an addition rather than a consequence of removing the spin,
and it is what an FFI consumer needs. A startup failure used to arrive as
"runtime thread failed to send handle" while the real io::Error went only
to a log sink the caller may never have installed. new_from_builder gets
the same treatment on both of its channels, which also recovers the reason
a database build failed.

Measured by forcing a build failure and catching at the C++ boundary:

  before: Failed to attach database: Runtime thread failed to build database
  after:  Failed to attach database: Failed to build database: missing
          parameter 'broker.url'

aimdb-sync is fmt clean, weather-station --features sync passes 16/2/4, and
make spike-cpp is all-green with close() at 0-1 ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRVG2B9A8hfX8JMiXsW6nD
Two passes of the review said different things about SyncConsumer and both
were wrong the same way. REQUIREMENTS.md filed it as an API shape to
reconsider; the last commit escalated it to the one release-gating finding.
probes/cr6-consumer-shape says neither.

&mut self is the honest signature: a reader is a cursor, poll_recv advances
it, so reading mutates. The question was never whether it could be &self but
what a consumer represents — and the answer makes the FFI shape fall out. A
consumer is a subscription. AimDbHandle::consumer takes &self, the handle is
Sync, and every call is a fresh subscribe_boxed with its own cursor: four
threads created one each concurrently and all four saw every value, at 891ns
per consumer.

So the consumer half of a C ABI needs no core change. One ws_consumer* per
thread, single-owner, the same ownership rule the header already states for
ws_station_free.

Two more things measured because they were the ones actually worth
worrying about: sixteen threads parked in get() at once are all served,
because Waiter::block_on drives on the calling thread rather than consuming
a runtime worker; and Arc<Mutex<SyncConsumer>> already compiles for the
genuinely shared case, since Mutex<T> is Sync whenever T is Send.

Moving that Mutex inside SyncConsumer — what CR-6 asked for — would impose
split semantics on the majority who want fan-out, and would break try_get's
never-blocks contract.

What is left is prose: say what a consumer is, and warn that replacing a
0.5.0 clone() with consumer() silently changes split into fan-out. That is
the one way this change can break a working program with no compile error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRVG2B9A8hfX8JMiXsW6nD
DbErrorKind, eight kinds, each naming a different thing the caller does:
Retry, Lagged, Closed, Transport, Data, Configuration, Usage, Internal.
Eight rather than StationErrorKind's three because a database has more
than three answers; the test is not how few but whether each kind is a
distinct action. Lagged is separate from Retry because the caller lost
data; Closed is separate because retrying cannot help.

SyncError::kind() returns the same enum rather than one of its own, so an
FFI layer has one switch for the whole stack. Db(e) delegates, timeouts are
Retry, RuntimeShutdown is Closed.

Both enums are now non_exhaustive, which follows a house style that already
exists: RpcError, CodecError and AuthError in session/mod.rs are all
non_exhaustive with small action-shaped variant sets. DbError and SyncError
were the outliers.

Both halves of the property verified rather than asserted. Adding a variant
inside aimdb-core is E0004 at kind(), so someone must decide which action it
belongs to in the file that owns it. A downstream match naming all 21
variants without a wildcard is also E0004, so reclassification cannot happen
silently at a boundary.

The blast radius is smaller than the variant count suggests: non_exhaustive
blocks matching, not construction, and all 24 real match arms in the
workspace are partial. aimdb-core, aimdb-sync, aimdb-tokio-adapter,
aimdb-mqtt-connector, aimdb-data-contracts and the mesh workspace all build
unchanged, and the spike stays green. The embassy and wasm adapters are not
reachable from this workspace and were read, not built.

Also exposed: AttachFailed carries a String, so it classifies as Internal
even when the cause was a configuration mistake — which after the CR-2 patch
is often exactly what its message says. Fixing it means carrying DbError
through the startup channel instead of String. Left out because it changes a
variant's shape, which is a decision rather than a cleanup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRVG2B9A8hfX8JMiXsW6nD
The only finding the spike measured as broken, and the only one whose fix
had to be a process-global, which is why it belonged in the core rather
than in the FFI layer.

There were two failures, not one. The original round ended the child with
_exit(0), so it never ran a destructor. Running one finds that joining a
JoinHandle for a thread that does not exist in this process panics inside
std with "threads should not terminate unexpectedly" — a Rust backtrace on
stderr from inside ~Station. Present before this review's changes too.

Detection is measured rather than assumed. try_set is 121ns and
std::process::id() is 321ns, so a pid check per publish would cost more
than twice the work it guards. Detection is a pthread_atfork child handler
and the hot-path check is a relaxed atomic load; re-measured after the
change, try_set is 107ns and the guard does not show up.

A generation counter rather than a flag, because a bool would poison the
child permanently — including for a database the child itself attaches
afterwards, which a supervisor that forks per job would hit for no reason.
The guard sits before the Weak upgrade deliberately: a forked child's
upgrade succeeds, since the Arc was copied with the address space, and that
is exactly why the buffer accepts a value nobody will read.

Producers, consumers and the factories refuse a stale generation with a new
SyncError::ForkedChild; detach and Drop release the join handle instead of
joining; StationHandle::is_closed reports closed in a child. The spike's
three notes are now four checks.

CR-5 paid for itself here: ForkedChild is a new variant, which before this
review would have been breaking. SyncError is non_exhaustive as of that
work, so it is additive.

fork::generation and fork::forked_since are public, which was not the plan.
is_closed must not take the mutex shutdown holds, so it cannot ask the
AimDbHandle whether the process forked — it records the generation itself.
Any FFI layer on aimdb-sync has the same problem for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRVG2B9A8hfX8JMiXsW6nD
rumqttc's own default is use-rustls. Both aimdb-mqtt-connector and
weather-station set default-features = false and then explicitly opted into
use-native-tls, so the system OpenSSL was chosen over the pure-Rust default.
The embedded half already went the other way — design 044's Embassy client
is embedded-tls with rustpki — so the host path was the outlier.

Three states now, not two: tokio-native-tls, tokio-rustls, or neither.
Neither is a genuine choice, because a deployment speaking mqtt:// on a
trusted network should link no TLS stack at all; mqtts:// then fails at
connect time with a message naming the missing feature. That third state
was the real work: rumqttc gates TlsConfiguration and Transport::Tls on
having a backend, so the whole mqtts branch has to be cfg'd, not just its
argument.

The choice lives in weather-station and is passed down, because the
pre-flight probe and the connector's data path must not disagree — two
backends in one process is two TLS stacks, and a pre-flight that succeeded
where the data path failed would defeat the point of probing.
weather-station's default is now tokio-runtime + native-tls, so every
existing station builds as before; weather-station-cpp, the crate that
actually ships as a shared library, takes rustls.

Measured on the cdylib: libssl.so.3 and libcrypto.so.3 are gone, and the
static link line drops -lssl -lcrypto. It costs size — 3.9 MB to 8.1 MB
release, 2.9 MB to 6.6 MB stripped — because rustls links the stack it no
longer borrows. That belongs in the decision, not in a footnote.

Found on the way: TlsConfiguration::default() expects and unwraps while
loading platform certs. Two panics on the connect path, in a crate reached
through an FFI boundary where a panic is undefined behaviour. Both backends
now build the configuration explicitly. CR-4's rule has to extend to what
the library calls, not just to what it writes.

make test and make clippy carry all the combinations now — this
repository's convention is that the feature matrix lives in the Makefile,
and a TLS backend is exactly the kind of choice that rots if CI only ever
sees one side of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRVG2B9A8hfX8JMiXsW6nD
These were cut to carry core changes that this repository cannot push.
They are being raised as PRs against aimdb instead, so the copies here
would only drift. review.md and REQUIREMENTS.md stay: they hold the
measurements and the CR-n numbering the PRs are derived from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (design 050 §10.2)

The C ABI door reached `tracing_subscriber::registry().…try_init()` from inside
a cdylib loaded into somebody else's process — deciding where that process's
diagnostics go, which is the one thing an FFI layer must not do. It did so
because a `tracing::Layer` has nowhere to put the caller's `void *user_data`,
and that single missing place is where both of this door's defects came from.

`ws_init_logging` now builds a `CSink` — callback, `user_data` and filter in one
value — and hands it to `log::set_boxed_logger`. The context travels with the
callback, and the first-wins decision is made once, in Rust, for every binding.

The header's `detail::SinkHolder` and `detail::sink_holder()` are gone with it.
Both defects were that static:

- it was assigned on every call, *before* asking whether the install would be
  accepted — last-wins over a first-wins C layer, so a second caller replaced
  the first caller's sink and returned false to say it had not;
- it was assigned from the calling thread while the trampoline read it from
  aimdb's runtime thread.

`init_logging` now allocates the `(sink, user_data)` pair the C ABI cannot carry
in one `void *`, hands it over as `user_data`, and releases it if the install is
refused. The trampoline stays — it is what catches a C++ exception that would
otherwise unwind into Rust, which is a separate concern from the static and
still load-bearing — but it holds nothing.

Measured by running the current spike against the previous header: 86,890
events went to the sink that had just been told it was not installed, and the
first caller's sink stopped receiving entirely. Against this one, zero. Two new
rounds in spike.cpp assert both halves, and the second is written so it cannot
pass vacuously.

`weather-station` gains a `log` feature mirroring its `tracing` one — required,
not cosmetic: the `log_*` macros expand in that crate, so the arm is selected by
its features, and without it every call site there also warned under
`unexpected_cfgs`. `init_tracing` and `tracing-subscriber` move out of
`tokio-runtime` into a new `init-tracing` feature, which `default` carries, so
the station binaries and templates are unchanged while a library build can have
the Tokio station without a subscriber. `cargo tree` for this door now contains
neither `tracing-subscriber` nor `tracing`.

Filtering is coarser, deliberately: `EnvFilter` came free with
`tracing-subscriber` and went with it. `filter` now takes `level` and
`target=level`, comma-separated, longest prefix first. Named as a regression in
README.md rather than left to be discovered.

Verified: `make clippy`, `make test`, `make fmt-check` clean; the spike's log
rounds all pass and still report `aimdb_core::builder` / `aimdb_core::session::pump`
targets. The five broker-path failures it reports are pre-existing — identical
on this branch before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgaHbjCPgkNS6Y34jxqVYH
… (design 050 §10.3)

Removing `init_tracing` from this module was meant to end one trespass: an
extension module is a library inside somebody else's application, and
process-wide logging is that application's decision. The replacement left half
of it standing. Reaching a `tracing::Layer` at all meant calling
`tracing_subscriber::registry().…try_init()`, so `init_logging` installed the
interpreter's global subscriber — the same trespass, one layer further down and
harder to see.

`PyLogger` is a `log::Log` instead: an ordinary destination this module owns.
Nothing global is installed on the application's behalf beyond the one logger
`log` exists to hold, and `log::set_boxed_logger` is what makes the `True`/
`False` honest — the first-wins decision is now made once, in `log`, for this
door and the C one alike rather than separately in each.

Everything Python-visible is unchanged: events still arrive on loggers named
after the emitting Rust module with `::` translated to `.`, so
`getLogger("aimdb_core").setLevel(...)` still silences aimdb without silencing
the station. The `MessageVisitor` goes with `tracing` — a `log::Record` carries
its message as `args()` and needs no field visitor.

`weather-station` gains a `log` feature mirroring its `tracing` one — required,
not cosmetic: the `log_*` macros expand in that crate, so the arm is selected by
its features. `init_tracing` and `tracing-subscriber` move out of
`tokio-runtime` into a new `init-tracing` feature that `default` carries, so the
station binaries and templates are unchanged while this crate can take the Tokio
station with `default-features = false` and get neither. `cargo tree -p
weather-station-py` now contains neither `tracing-subscriber` nor `tracing`.

Filtering is coarser, deliberately: `EnvFilter` came free with
`tracing-subscriber` and went with it. `filter` now takes `level` and
`target=level`, comma-separated, longest prefix first, in either spelling —
directive prefixes are normalised to `::` at construction, so a Python caller
can write the dotted logger names this bridge hands out and still match, without
paying for a translation per event on the path the gate exists to keep cheap.
It costs less here than at the C door: `filter` is only the floor that keeps
events from acquiring the GIL, and Python's own per-logger levels do the
fine-grained work above it as they always did. Named as a regression in
README.md rather than left to be discovered.

Verified: `make clippy`, `make test`, `make fmt-check` clean, and `make spike`
reports all checks passed against a live broker — including "the module does not
install a global tracing subscriber", which is now true by construction rather
than by convention, and "one subsystem can be silenced without silencing the
others".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgaHbjCPgkNS6Y34jxqVYH
This branch is nine commits behind `weather-station-cpp`, and one of them —
`fafe259` (#10) — tightened the doc comments throughout
`weather-station-py/src/lib.rs`. The previous commit was written against the
older text, so merging it would have reverted that work in every block it
touched, and left the blocks it did not touch looking like a revert too.

Re-applied the design-050 change on top of #10's version of the file instead.
No behaviour change from the previous commit: `PyLogger` and `init_logging` are
the same, and the module-doc note about the bridge is rewritten in #10's
register rather than the older one. The diff against #10 is now confined to the
imports, the module-doc tail and the bridge itself — `PyStation` is untouched.

#10's change to this crate was comment-only, so nothing here depended on it.

`make spike` still reports all checks passed; `make clippy`, `make test` and
`make fmt-check` clean.

Note for whoever merges: this branch is still missing the other eight commits
on `weather-station-cpp`, including the C door itself. Which way those branches
should converge is a topology decision, not one this commit makes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgaHbjCPgkNS6Y34jxqVYH
Brings the design-050 migration of the pyo3 door onto the branch that carries
both. `weather-station-cpp` was nine commits ahead, so its copy of the Python
door was still installing the interpreter's global `tracing` subscriber while
its `weather-station` had already moved to the `log` facade.

Three conflicts, all in files both sides edited:

- `weather-station-py/src/lib.rs` — cpp's side was #10's doc tightening, which
  the py branch had already been rebuilt on top of, so the migrated version is a
  strict superset. Took it whole; the result is byte-identical to the py
  branch's file.
- `weather-station/Cargo.toml`, `default` — kept `native-tls`. The py branch
  predates CR-11 and had no TLS backend to name; dropping it here would have
  left the default build without one.
- the same file's `log` feature comment — one word apart; kept "destination",
  which is the design's vocabulary.

One thing neither branch could have got right alone: `weather-station-py` takes
`weather-station` with `default-features = false`, and on the py branch that was
harmless because a TLS backend was not yet selectable — `rumqttc` named
`use-native-tls` directly. Merged, it means no backend is selected at all and
`mqtts://` would have failed at run time with nothing failing at build time.
Named `rustls` explicitly, for the reason CR-11 gives and which describes this
crate exactly: a build that must not hand its host process a second OpenSSL —
"an FFI layer shipped as a shared library, above all". The C door made the same
call for the same reason. `cargo tree -p weather-station-py` links no OpenSSL
(`openssl-probe` is `rustls-native-certs`' cert-store locator, not a link to
OpenSSL) and still contains neither `tracing` nor `tracing-subscriber`.

That is the one judgement call in this merge rather than a mechanical
resolution; `native-tls` is a one-word change if the wheel should keep it.

Verified on the merged tree: `make clippy`, `make test`, `make fmt-check` clean;
`make spike` and `make spike-cpp` both report all checks passed against a live
broker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgaHbjCPgkNS6Y34jxqVYH
`make clippy` fails on rustc 1.98 with `clippy::unnecessary_sort_by`:
`sort_by(|a, b| b.0.len().cmp(&a.0.len()))` is a reversed comparison that
`sort_by_key` with `Reverse` expresses directly. Same descending order, same
stable sort — the longest-prefix-first invariant both filters rely on is
unchanged.

Both doors carried the identical line; CI only reported the Python one because
`make clippy` stops at the first failing package, so the C door's copy was
never reached. Fixed in both rather than one at a time.

Why local checks missed it: this sandbox was on rustc 1.94.1, where the lint
does not fire on a reversed comparison, while CI runs 1.98.0. Toolchain updated
to 1.98.0 here, the failure reproduced against the old code, and the whole
suite re-run on it — `make clippy`, `make test`, `make fmt-check`, `make
lockfile` clean, and `make spike` / `make spike-cpp` both report all checks
passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgaHbjCPgkNS6Y34jxqVYH

lxsaah commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

Closing in favour of #12, which continues this branch: bf4514e is an ancestor of its head, so every commit here is included there, plus a station template in each language and the cleanup that followed.

Nothing is lost by closing this one.


Generated by Claude Code

@lxsaah lxsaah closed this Aug 28, 2026
@lxsaah
lxsaah deleted the weather-station-cpp branch September 16, 2026 20:30
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