Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 15 additions & 22 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,29 +177,22 @@ jobs:
run: cargo build --workspace --exclude e2e-tests --all-features --all-targets --verbose
- name: Clippy (all features)
run: cargo clippy --workspace --exclude e2e-tests --all-features --all-targets -- -D warnings
# No `cargo test --all-features`: it enables the `danger-skip-*-verify` flags, which disable
# security verification (the cert-chain negative test is even cfg'd out), so the suite would
# change behavior rather than catch rot; build + clippy cover that. The voip suite runs under
# the explicit `voip` feature set instead, never `--all-features`.
- name: Test (voip)
# There is no `cargo test --all-features` because a few features cannot
# share a build with the rest. `scripts/ci/test_features.sh` derives the
# set that can from cargo metadata and carries the exclusions with their
# reasons, so a feature added tomorrow is tested without editing CI.
# Compiling a test is not running it: the two `--all-features` steps above
# build the codec suite, its golden checksums and every C/Go ground-truth
# vector among them, but nothing executed them until a step actually
# enabled `voip-mlow` and ran it. That is what this loop does.
- name: Test (every shareable feature)
run: |
cargo nextest run --profile ci -p wacore --features voip --lib
cargo nextest run --profile ci -p whatsapp-rust --features "voip tokio-native tokio-transport" --lib
# `voip` does NOT imply `voip-mlow`, and wacore has no default features, so the `Test (voip)`
# step above compiles `voip::mlow` away entirely. The two `--all-features` steps at the top of
# this job do compile it, tests and all, but compiling a test is not running it: the codec's
# tests, the golden checksums and every C/Go ground-truth vector among them, executed nowhere
# in CI before this step.
- name: Test (voip-mlow)
run: cargo nextest run --profile ci -p wacore --features voip-mlow --lib -E 'test(voip::mlow)'
# legacy-session-interop is off by default, so every other test job
# compiles its module away and never runs a single one of its tests.
- name: Test (legacy-session-interop)
run: cargo nextest run --profile ci -p wacore-libsignal --features legacy-session-interop
# `tracing` is off by default, so span-scope assertions compile to nothing
# everywhere else. They are the only gate on which work a span owns.
- name: Test (tracing)
run: cargo nextest run --profile ci -p whatsapp-rust --features tracing --test handshake_span_scope
for package in wacore wacore-libsignal whatsapp-rust; do
features="$(./scripts/ci/test_features.sh "$package")"
echo "::group::$package [$features]"
cargo nextest run --profile ci -p "$package" --features "$features" --lib --tests
Comment thread
jlucaso1 marked this conversation as resolved.
echo "::endgroup::"
done

rustdoc:
name: Rustdoc
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Read the one that covers what you are touching:
| `agent_docs/protocol_architecture.md` | Building or parsing stanzas: `ProtocolNode`, `IqSpec`, derive macros, node helpers |
| `agent_docs/noise_handshake.md` | Connection setup: XX/IK/fallback selection, server cert cache, failure classification |
| `agent_docs/feature_implementation.md` | Starting a feature and needing its wire format from captured WA Web JS |
| `agent_docs/subsystem_boundary.md` | Adding a feature gate, adding a `Client` field only one subsystem reads, or proposing that a subsystem leave the core |
| `agent_docs/signal_durability.md` | Any code that reads, mutates, persists, or sends Signal state |
| `agent_docs/e2e_testing.md` | Writing or fixing tests under `tests/e2e/` |
| `agent_docs/observability.md` | Adding a cache, counter, or anything reported by `memory_report()` / `stats()` |
Expand Down
6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ description = "Rust client for WhatsApp Web"
autobenches = false

[package.metadata.docs.rs]
features = ["plugins"]
features = ["passkey", "plugins"]
rustdoc-args = ["--cfg", "docsrs"]

[workspace]
Expand Down Expand Up @@ -152,6 +152,10 @@ client-lifecycle = []
# Build-time native plugin host. Kept opt-in so clients that do not use plugins
# retain the pre-host binary footprint.
plugins = ["client-lifecycle", "dep:bon"]
# SHORTCAKE_PASSKEY linking flow. Opt-in so a client that links by QR or pair
# code carries neither the flow nor any state for it; the core keeps no field
# and no branch either way. See agent_docs/subsystem_boundary.md.
passkey = []
# Optional observability. Off by default: no `tracing` dep, zero overhead.
# Emits tracing spans/events only; the application installs the subscriber
# (and any OpenTelemetry bridge). See examples/observability.rs.
Expand Down
154 changes: 154 additions & 0 deletions agent_docs/subsystem_boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Subsystem Boundary

The core compiles conditionally for three optional subsystems and nobody had
measured what any of them cost. This is the rule that decides whether a
subsystem may stop being part of the core, the classification of every optional
subsystem against it, and the numbers behind both.

Read it before adding a feature gate, before adding a `Client` field only one
subsystem reads, and before proposing that a subsystem move out of the tree.

Anchors here are files and symbols, never line numbers: a `file:line` citation
in a document nobody recompiles is wrong within a week.

## Where the counts come from

```sh
grep -rc 'cfg(feature = "<name>")' --include='*.rs' src
```

Counted at `ff4ac10`, production sites only, since a gate inside a `mod tests`
block is scaffolding rather than coupling: `voip-runtime` 171, `plugins` 87,
`client-lifecycle` 56.

The same VoIP subsystem is 47 gates for 46k lines in `wacore`, where `voip` is
one gated `mod` and everything under it is unconditional. The difference is not
the subsystem, it is whether the subsystem owns its own files.

## The cut rule

A subsystem is **cuttable** when all four tests pass.

1. **Reach.** The core enters it on a dispatch key the core already routes on (a
stanza tag, a notification type, an IQ namespace), or not at all. A core
function that runs subsystem statements inline fails this.
2. **State.** Its per-client state is read only by itself.
3. **Return.** Everything it needs from the core is already `pub` or
`pub(crate)` for some other caller.
4. **Contract.** Nothing it owns changes shape with the feature. `Event` is
exempt from removal but not from mutation: `EventKind` discriminants are
`EventInterest` bit indices consumers persist, so a cut subsystem keeps its
variants and payload types compiled unconditionally. What fails this test is
a payload *field* behind a `cfg`, because then one public type has two
shapes.

Verdicts:

- **Cuttable.** All four pass. The core may name it in exactly two places: its
`mod` declaration and its entry in `SUBSYSTEMS` (`src/client/subsystem.rs`).
`tests/subsystem_boundary.rs` fails on a third.
- **Coupled.** Fails 1 or 2. It can be *disciplined* (interleaved statements
hoisted into files it owns, one call per seam) but not cut, because the seam
it needs does not exist yet.
- **Structural.** It is a core seam or a platform adapter slot, not a passenger.
Its gate count is inherent.
- **Cross-cutting.** Instrumentation, gated at the point being instrumented by
definition.

The rule deliberately does not say "a subsystem with its own directory can
leave": `src/voip/` has one and is not cuttable. Nor "a big subsystem should
leave": `src/message` is the largest thing in the crate and is the hot path, not
a subsystem.

## Inventory

### Cuttable

| subsystem | why | status |
| --- | --- | --- |
| `passkey` (`src/passkey/`) | claims two notification types and nothing else; state is its own; needs only `persistence_manager`, the event bus and `query`; owns `Event::PairPasskey*` with no gated field | cut, behind the `passkey` feature |

### Coupled

| subsystem | the edge that fails | test |
| --- | --- | --- |
| `voip-runtime` | `bind_pending_call_link_join_ack` runs inline in the ack path (`src/client/node_io.rs`) | 1 |
| | `call_registry` is read by `CallHandler` and by `memory_report` | 2 |
| | `would_emit_pkmsg` (`src/client/sessions.rs`) and `should_issue_tc_token` (`src/send/tctoken_lifecycle.rs`) exist only for it | 3 |
| | `IncomingCall::media` is a `cfg` field inside a public payload (`wacore/src/types/call.rs`) | 4 |
| `pdo` (`src/pdo.rs`) | driven from the retry pipeline, and `pdo_requested` is the memo that keeps retry idempotent | 1, 2 |
| `pair_code` (`src/pair_code.rs`) | `pair_code_state` is written by the companion-reg notification handler, by `src/pair.rs` and by connection cleanup | 2 |
| `features/groups`, `features/newsletter`, `features/business`, `features/mex` | outbound IQ in `src/features/`, inbound handling in `src/handlers/notification/`, so neither half owns the subsystem; `group_cache` is also read from `src/voip/facade.rs` | 1, 2 |

### Structural

`client-lifecycle` is the generation-scoped seam; `plugins` is the generic host,
and its gate count is the price of the seam existing. `sqlite-storage`,
`tokio-transport`, `tokio-runtime`, `ureq-client`, `signal` and `tokio-native`
are platform adapter selection. `voip-mlow`, `voip-libopus` and `voip-encoded` are codec profiles inside `voip`. `bench-harness`,
`debug-snapshots`, `legacy-session-interop` and `danger-skip-*` are build-time
switches.

### Cross-cutting

`tracing` and `metrics`. Their gates are not coupling.

### Not subsystems

`src/message`, `src/send` and the shared plumbing under `src/features` are the
hot path and the core's own work. They fail tests 1 and 2 by construction.

## The seam

`src/client/subsystem.rs` holds one `const` table:

```rust
pub(crate) const SUBSYSTEMS: &[Subsystem] = &[
#[cfg(feature = "passkey")]
crate::passkey::SUBSYSTEM,
];
```

`Client` gains one field, `subsystems`, not one per subsystem. A subsystem parks
its per-client state there and lists the notification types it models. With none
attached the table is a zero-length slice, so every loop over it folds away.

The table is a `const` rather than runtime registration because static
registration through a linker-section crate would trade the core's last gate for
a new dependency. The guard test is what keeps "one gate" enforceable instead.

The core's own match arms win: the table is consulted only for a notification
type the core does not model itself, so a claim on a type the core later starts
handling would silently stop arriving.
`a_claimed_notification_type_is_not_shadowed_by_a_core_arm` fails when that happens.

## What a subsystem costs

Stripped `demo`, release profile, the build `binary_size_ci.md` gates on. Sizes
are deterministic for a pinned toolchain; the baseline reproduced byte for byte
across two runs.

| build | bin size | vs default |
| --- | ---: | ---: |
| default, `passkey` compiled in unconditionally (the old shape) | 10,806,752 | |
| default, `passkey` off | 10,756,992 | -48.6 KiB |
| default + `passkey` | 10,809,824 | +51.6 KiB |
| default + `plugins`, host on and no plugin installed | 10,960,960 | +150.6 KiB |
| default + `voip` | 11,373,952 | +553.9 KiB |

Turning the smallest cuttable subsystem off is worth ~49 KiB, and the seam that
makes it cuttable costs 2.5 KiB of `.text` when the subsystem is on. The
`plugins` row is the enabled-with-no-plugin number `plugin_architecture.md`'s
checklist asks for and that nothing in the repo had produced.

## What the guard proves, and what it does not

`tests/subsystem_boundary.rs` fails when a cuttable subsystem is named outside
the files it owns and its two allowed core mentions. It scans text, so it sees a
mention in a comment too, which is deliberate: a comment in the core explaining
what a subsystem needs is the same coupling one commit early.

It does not reach test 3 (the subsystem calling core internals that exist only
for it), and it does not claim the disabled build carries zero bytes of the
subsystem: `Event` variants and payload types stay in `wacore` by test 4. "Zero
cost" here means zero code, state and branches of the subsystem's own.
24 changes: 24 additions & 0 deletions scripts/ci/test_features.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Prints the features of a package that the test suite runs with all at once,
# comma-separated for `cargo nextest --features`.
#
# Derived from cargo metadata so a feature added tomorrow is exercised without
# anyone editing CI. Only a feature that cannot share a build with the rest
# needs a line below, and each says why it cannot.
set -euo pipefail

package="${1:?usage: test_features.sh <package>}"

# danger-skip-* disable security verification, so the suite would change
# behavior rather than catch rot; the cert-chain negative test is
# itself cfg'd out under them.
# dhat-heap installs a global allocator, colliding with the counting one
# the allocation guards install.
# js, getrandom wasm32 backend selection, with no native build to join.
excluded='default|danger-skip-.*|dhat-heap|js|getrandom'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

cargo metadata --no-deps --format-version 1 \
| jq -r --arg package "$package" \
'.packages[] | select(.name == $package) | .features | keys[]' \
| grep -vxE "$excluded" \
| paste -sd,
15 changes: 7 additions & 8 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod node_io;
pub(crate) mod offline_resume;
mod sender_keys;
mod sessions;
pub(crate) mod subsystem;
mod voip;
use builder::{ClientAssembly, ClientExtensions};
pub use builder::{ClientBuild, ClientBuilder, ClientBuilderError};
Expand Down Expand Up @@ -1544,14 +1545,12 @@ pub struct Client {
/// Tracks the pending pair code request and ephemeral keys.
pub(crate) pair_code_state: Arc<Mutex<wacore::pair_code::PairCodeState>>,

/// SHORTCAKE_PASSKEY linking flow state: the pending handoff key, the
/// per-attempt ephemeral linking cache, and the optional host authenticator.
pub(crate) passkey_state: Arc<Mutex<crate::passkey::flow::PasskeyFlowState>>,

/// Wait-free "an open is in flight" reservation for the passkey flow. Kept
/// outside `passkey_state` so it can be released synchronously on drop (a
/// cancelled open can't leave it stuck), unlike a flag behind the async lock.
pub(crate) passkey_opening: AtomicBool,
/// Per-client state of every optional subsystem attached to this build,
/// in one field rather than one field per subsystem. The core does not
/// know what is in it, and in a build with none attached nothing reads it;
/// see `agent_docs/subsystem_boundary.md`.
#[allow(dead_code)]
pub(crate) subsystems: subsystem::Subsystems,

/// Custom handlers for encrypted message types. Set once at `Bot::build` and
/// immutable afterward, so the receive hot path reads it with a plain
Expand Down
3 changes: 3 additions & 0 deletions src/client/accessors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,9 @@ mod identity_span_tests {
Some(pn.observe().to_string().as_str()),
"pn field must render through the redacting wrapper, not raw"
);
// `tracing-pii` exists to render the number raw, so this is the one
// claim it invalidates; same split as `observe_redacts_phone_but_not_lid_or_group`.
#[cfg(not(feature = "tracing-pii"))]
assert!(
!recorded(&captured, "pn")
.expect("pn recorded")
Expand Down
3 changes: 1 addition & 2 deletions src/client/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,8 +523,7 @@ impl Client {
pairing_cancellation_tx: Arc::new(Mutex::new(None)),
pairing_qr_refresh_tx: Arc::new(Mutex::new(None)),
pair_code_state: Arc::new(Mutex::new(wacore::pair_code::PairCodeState::default())),
passkey_state: Arc::new(Mutex::new(crate::passkey::flow::PasskeyFlowState::default())),
passkey_opening: AtomicBool::new(false),
subsystems: subsystem::Subsystems::default(),
signal_flush_state: AtomicU64::new(0),
signal_flush_lifecycle: Mutex::new(()),
#[cfg(test)]
Expand Down
Loading
Loading