Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions .cargo-husky/hooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,25 @@ cargo clippy --workspace --no-default-features --all-targets -- -D warnings
# E0599 that breaks `cargo build --release --no-default-features`.
cargo clippy --workspace --no-default-features -- -D warnings

# Feature-gated code, which neither pass above COMPILES at all (#1380) — see scripts/gate.sh for the
# full reasoning and the two stated limits. Here because rot is a per-push class and this hook is the
# only check that runs on every push (#1144). Worst case (an edit in openpulse-core) this re-checks
# the workspace in a second feature configuration, ~40 s measured; a leaf-crate edit is seconds.
#
# The preflight exists so a missing distro package reads as a missing distro package: --all-features
# pulls alsa-sys, libudev-sys and libdbus-sys, whose build scripts call pkg_config and PANIC, which
# would otherwise surface as a backtrace buried in clippy output.
missing_pc=""
for pc in alsa libudev dbus-1; do
pkg-config --exists "$pc" 2>/dev/null || missing_pc="$missing_pc $pc"
done
if [ -n "$missing_pc" ]; then
echo "pre-push: FAIL — --all-features needs pkg-config:$missing_pc"
echo "pre-push: install libasound2-dev libudev-dev libdbus-1-dev (Debian/Ubuntu names)"
exit 1
fi
cargo clippy --workspace --all-features --all-targets -- -D warnings

if [ -z "${pkgs// /}" ]; then
echo "pre-push: no crate-owned changes detected; fmt + clippy only."
exit 0
Expand Down
60 changes: 15 additions & 45 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ jobs:
# enforced by no machine (CLAUDE.md "Known hole"). It cost #1074: four tests red on `main`,
# plus a fifth gone vacuous, unnoticed. The hook now tests touched crates on every push; this
# is the full gate, and it is the thing that actually closes the hole.
# `gate.sh`'s --all-features rot guard (#1380) links against ALSA, libudev and D-Bus through
# alsa-sys / libudev-sys / libdbus-sys, whose build scripts call pkg_config and PANIC when a
# .pc file is absent. The ubuntu runner image ships none of these three -dev packages, so
# without this step the gate fails on arrival. NOT `|| true`: a missing library here means the
# rot guard cannot run, and the gate's own preflight fails loudly rather than skipping it.
- name: Install feature-gate build dependencies
run: |
sudo apt-get update
sudo apt-get install -y libasound2-dev libudev-dev libdbus-1-dev

- name: Workspace gate (fmt + clippy + tests)
run: ./scripts/gate.sh

Expand Down Expand Up @@ -178,51 +188,11 @@ jobs:
- name: Build workspace on macOS
run: cargo build --workspace --no-default-features

gpu-feature-gates:
name: GPU feature gates (compile + lint)
runs-on: ubuntu-latest
if: ${{ startsWith(github.head_ref, 'release/') || github.event_name == 'workflow_dispatch' }}

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
with:
toolchain: ${{ env.REQUIRED_RUST }}

- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
# One cache per job, so a lint-only job cannot evict the test job's artifacts.
shared-key: ${{ github.job }}

- name: Toolchain preflight
run: ./scripts/check-toolchain.sh $REQUIRED_RUST

# The per-plugin `gpu` feature is never compiled by the --no-default-features
# gates above, so its `#[cfg(feature = "gpu")]` paths rot silently (PR #424
# found a build break + clippy + flaky-test issue only reachable here). This
# job compiles and lints the GPU paths on every change. It does NOT run the
# GPU tests — CI runners have no wgpu adapter, so those would only exercise
# the CPU fallback; compile + lint is the rot guard.
- name: GPU feature build gate
run: >
cargo build --no-default-features --features gpu
-p bpsk-plugin -p qpsk-plugin -p psk8-plugin -p qam64-plugin -p scfdma-plugin

- name: GPU feature clippy gate
run: >
cargo clippy --no-default-features --features gpu --all-targets
-p bpsk-plugin -p qpsk-plugin -p psk8-plugin -p qam64-plugin -p scfdma-plugin
-- -D warnings

# openpulse-gpu's adapter-requiring differential tests are behind
# `hardware-tests`, so the workspace gate never compiles them. Same rot guard
# as above: compile + lint only, never run — this runner has no adapter.
- name: GPU hardware-test rot guard (compile + lint, not run)
run: >
cargo clippy --features hardware-tests --all-targets
-p openpulse-gpu -- -D warnings
# `gpu-feature-gates` was REMOVED here (#1380). It compiled and linted the `gpu` and
# `hardware-tests` paths, which `scripts/gate.sh`'s `--all-features` pass now covers for every
# crate rather than a named five — and covers on every LOCAL gate run and in post-merge-gate,
# where this job never ran at all, being `release/**`-scoped like the rest of this file
# (#1120/#1144). Its reasoning survives in that step's comment, PR #424 precedent included.

pi5-smoke-loopback:
name: Pi5 smoke profile (loopback)
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/post-merge-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ jobs:
# One verdict path — `scripts/gate.sh`, never an open-coded `cargo test`. Without
# `--no-fail-fast` cargo stops at the first failing binary and the count is a lower bound
# (CLAUDE.md verification rule 2); the script is what guarantees the flag and the `GATE:` line.
# `gate.sh`'s --all-features rot guard (#1380) links against ALSA, libudev and D-Bus through
# alsa-sys / libudev-sys / libdbus-sys, whose build scripts call pkg_config and PANIC when a
# .pc file is absent. The ubuntu runner image ships none of these three -dev packages, so
# without this step the gate fails on arrival. NOT `|| true`: a missing library here means the
# rot guard cannot run, and the gate's own preflight fails loudly rather than skipping it.
- name: Install feature-gate build dependencies
run: |
sudo apt-get update
sudo apt-get install -y libasound2-dev libudev-dev libdbus-1-dev

- name: Workspace gate (fmt + clippy + tests)
id: gate
run: ./scripts/gate.sh
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ Each requirement below is done when the linked test passes. Add new links as tes
| A repeater that is not running is not reported as running (#1298) — enabling with nothing to run FAILS with a reason instead of emitting `RepeaterChanged { enabled: true }`, and a thread that exited is reaped so the next command sees the truth rather than "already enabled" forever. The thread OWNS the `CrossBandRepeater`, so its exit means the repeater is gone | `cargo test -p openpulse-daemon --no-default-features --lib command_apply_tests` |
| The cross-band repeater receives what the DAEMON hears (#1297, #1308) — it holds no capture stream of its own; the daemon's accumulator flushes one burst to two consumers (monitor and repeater) over a bounded lossy channel. Three lifecycle cases, because **none of them worked before 2026-09**: `[repeater] enabled = true` relays with no command (startup used to set the flag and spawn nothing), `enable_repeater` starts a config-disabled one (it used to report success and return `Ok(0)` at once), and an "already enabled" refusal is only issued when it is true. Sabotage-verified: removing the startup spawn fails cases (a) and (c) and leaves (b) green; removing the burst `try_send` fails all three | `cargo test -p openpulse-daemon --no-default-features --test repeater_relays_a_daemon_burst` |
| Every binary that builds a `ModemEngine` pins `[audio] device` (#1311, #1308) — ARDOP, KISS and the TUI passed a hardcoded `None` per call, so on a multi-card host they took the OS default. The daemon's own two **repeater** engines did the same until #1308 PR 3, which is the sharpest case: a cross-band repeater is by definition a two-card station, so the default is very likely the MAIN rig. The scan now matches the pin to the constructed **binding** (`let mut rx = …` needs `rx.set_default_device`) and blanks `#[cfg(test)]` modules — a line window alone passed with `rx`'s pin deleted, because it found `tx`'s 15 lines away. Validated against a planted violation, a planted fix, a planted in-test fixture, and three independent live sabotages | `cargo test -p openpulse-modem --no-default-features --test front_ends_pin_the_audio_device` |
| The file keystore is **available as the fallback** when no usable system secret store is present, and falling back is **never silent** (REQ-CTL-04's fallback clause, #1234). The selector takes a **three-state** probe — `NotCompiledIn \| Unreachable \| Reachable` — not a boolean, because a boolean cannot distinguish "no secret service on this host" from "no keychain in this BUILD", and the second is the case that ships: the gate builds `--no-default-features`, so `probe_keychain` really does return `NotCompiledIn` and the shipped binary really does take the fallback. That is what the fourth test pins, and it is why this is a gate rather than a truth table. `select_backend` is deliberately NOT behind `cfg(feature = "keychain")` — a selector compiled only when the feature is on is never type-checked by the gate, which is #1380's shape. **Stated limit: the `KeychainStore` body itself is still never type-checked here**, so #1380 is contained, not closed, and the OS-store surface stays manual-only (its sole evidence is the `#[ignore]`d `keychain_round_trip`; REQ-CTL-03's id was retired in #1234 while the requirement stands). Sabotage-verified three ways: making `NotCompiledIn` prefer the keychain fails the fallback test AND the gate's-own-build test; returning `None` for the reason, or making the two reasons identical, each fail the reason test | `cargo test -p openpulse-keystore --no-default-features store::selection` |
| The file keystore is **available as the fallback** when no usable system secret store is present, and falling back is **never silent** (REQ-CTL-04's fallback clause, #1234). The selector takes a **three-state** probe — `NotCompiledIn \| Unreachable \| Reachable` — not a boolean, because a boolean cannot distinguish "no secret service on this host" from "no keychain in this BUILD", and the second is the case that ships: the gate builds `--no-default-features`, so `probe_keychain` really does return `NotCompiledIn` and the shipped binary really does take the fallback. That is what the fourth test pins, and it is why this is a gate rather than a truth table. `select_backend` is deliberately NOT behind `cfg(feature = "keychain")` — a selector compiled only when the feature is on is never type-checked by the gate, which is #1380's shape. **Corrected 2026-09-20 (#1380): the `KeychainStore` body IS now type-checked**, by `gate.sh`'s `--all-features` pass — on Linux, and compile+lint only, and the OS-store surface stays manual-only (its sole evidence is the `#[ignore]`d `keychain_round_trip`; REQ-CTL-03's id was retired in #1234 while the requirement stands). Sabotage-verified three ways: making `NotCompiledIn` prefer the keychain fails the fallback test AND the gate's-own-build test; returning `None` for the reason, or making the two reasons identical, each fail the reason test | `cargo test -p openpulse-keystore --no-default-features store::selection` |
| The ARDOP TNC accumulates a frame **across reads**, tries `[Rs, None]` for a sticky `FECRCV`, and holds **no** capture stream while adaptive ARQ is active (#1310 PR1c). It called `receive`/`receive_with_fec` in a free-running poll loop, so on a callback backend each call saw one 5 ms poll against a seconds-long frame and the TNC could not receive on real audio — invisible to the suite because `LoopbackBackend::read` drains its whole buffer. Six keyed sites now `drop_stream()` first, with the release next to the keying rather than at the call site. `FECRCV` **stores `true` and nothing clears it** while `FECSEND` is a per-frame one-shot, so the old either/or made one `FECRCV` lose every uncoded frame for the session (the peer's `DE <call>` ID, any relay envelope); the burst is in hand, so the second candidate is one bounded scan. **The adaptive path is deliberately NOT converted** — the ACK listen and the adaptive IRS arm open their own stream via `stage_capture_input`, and `enable_adaptive_arq` defaults to `false`, so what is fixed is the shipped default. Its gate pins the **high-water mark of simultaneously live streams**, not an open count: a first draft counted opens and was **vacuous** — measured, it passed with the guard sabotaged — because the adaptive arm reopens per call either way. Sabotage-verified three ways, each failing its own case: IRS reverted to one-shot fails the two receive tests; `fec_rx` back to either/or fails only the uncoded-with-`FECRCV` test; making the adaptive branch tick fails only the concurrency test, at "2 capture streams open at once" | `cargo test -p openpulse-ardop --no-default-features --test receives_a_chunked_capture` |
| The **OTA** coded arm scans with slices sized for the CODED frame too (#1384) — the same shape as #1310 PR1b, one caller over, and the one the daemon actually runs. Both OTA onset scans took `max_frame_samples` from `burst_onset_scan_bounds`, i.e. the plugin's RAW geometry. **Filed as a code read and then MEASURED before any fix**, which is what made it real: on BPSK250 the raw geometry is **74 624** samples while a coded frame past the one-block boundary is **131 840**, and decode at a non-zero onset went 200 B ✓ / 205 B ✓ / **210 B ✗ / 255 B ✗**. Offset 0 is exempt — the attempt before the scan decodes the whole burst — which is why it sat behind a green suite. The measurement also **corrected the boundary to payload ≤ 209 B** (`FecCodec::encode` prepends a 4-byte length prefix before blocking, so the RS input is `4 + payload + 10` against 223). Sabotage-verified: reverting both sites to raw sizing fails ONLY the two-block case, with the 200 B control still passing | `cargo test -p openpulse-modem --no-default-features --test ota_burst_sizes_for_the_fec` |
| A **coded** burst is scanned with slices sized for the CODED frame (#1310 PR1b) — `decode_burst` took its per-attempt slice from the plugin's **raw** `max_frame_samples` (sized for one RS block plus envelope) and hardcoded `FecMode::None` at the decode, so it was an uncoded-only entry point and the ARDOP/KISS front ends had no coded burst path. The boundary is exact and is what the fixture is built on: `FecCodec::encode` prepends a **4-byte length prefix** (`PREFIX_LEN`) before blocking, so the RS input is `4 + payload + Frame::WIRE_OVERHEAD(10)` and one RS(255,223) block holds 223 of it — i.e. **payload ≤ 209 B**. (**Corrected 2026-09-17 from 213 B**, which omitted the prefix; #1384 MEASURED the transition between 205 B and 210 B, and 213 B is on the two-block side. The gates are unaffected — 200 B and 255 B sit on the correct sides of 209 either way.) A gate written at or below the boundary passes with the widening deleted and proves nothing — so the discriminating pair is 255 B (two blocks, must decode) against a 200 B **control** (one block, must also decode), both at a **non-zero onset**, since offset 0 is exempt by construction. Sabotage-verified twice, each failing a distinct set: raw sizing fails ONLY the two-block case while the control passes (which is what proves the failure is about sizing, not about coded bursts); re-hardcoding `FecMode::None` fails BOTH coded cases and leaves the uncoded one green | `cargo test -p openpulse-modem --no-default-features --lib burst_decode_sizes_for_the_fec` |
Expand All @@ -270,7 +270,7 @@ Each requirement below is done when the linked test passes. Add new links as tes
| Winlink header fields are capped, and a realistic multi-recipient message still decodes | `cargo test -p openpulse-b2f --no-default-features -- header_decode_caps header_decode_allows_a_realistic` |
| B2F driver survives a hostile peer — line cap, per-operation read deadlines, framing edges | `cargo test -p openpulse-b2f-driver --no-default-features --test cmd_hardening` + `--test timeout_hardening` + `--test data_framing` |
| B2F driver reports a refused or fully-rejected ISS transfer instead of silent success | `cargo test -p openpulse-b2f-driver --no-default-features --test iss_failure_paths` |
| CI gates are defined and correct (Linux core/full/gpu/pi5 + `macos-build`) — **but the `CI` workflow is `disabled_manually` by the maintainer, so they do NOT run on a PR; the gates above are run locally before every merge** | `.github/workflows/ci.yml` `on: pull_request` (definition only; check state with `gh api repos/dc0sk/OpenPulseHF/actions/workflows`) |
| CI gates are defined and correct (Linux core/full/pi5 + `macos-build`; the `gpu` job was removed in #1380, subsumed by `gate.sh`'s `--all-features` pass) — **but the `CI` workflow is `disabled_manually` by the maintainer, so they do NOT run on a PR; the gates above are run locally before every merge** | `.github/workflows/ci.yml` `on: pull_request` (definition only; check state with `gh api repos/dc0sk/OpenPulseHF/actions/workflows`) |

For any new Phase 1 feature: write the test first, confirm it fails, implement until it passes. Do not mark a task done if its test does not exist.

Expand Down
2 changes: 1 addition & 1 deletion apps/openpulse-linksim/src/gui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ fn constellation_plot(
.include_y(-1.8)
.include_y(1.8)
.show(ui, |p| {
p.points(Points::new(pts).radius(1.2).color(color));
p.points(Points::new(pts).radius(1.2_f32).color(color));
});
});
});
Expand Down
4 changes: 4 additions & 0 deletions apps/openpulse-linksim/tests/serve_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ fn demo_params() -> LinkParams {
turnaround_s: 0.2,
max_attempts: 4,
seed: 99,
// Both match `LinkParams::default()`: this fixture is about the serve/hub transport, not
// about conditioning or notching, so it takes the engine-matching defaults (#1380).
cessb_enabled: true,
notch: None,
}
}

Expand Down
10 changes: 5 additions & 5 deletions crates/openpulse-radio/src/gpio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,14 @@ impl GpioPtt {
/// Requires the `gpio` feature; without it, returns an error. Leaves PTT released.
pub fn open(spec: &str) -> Result<Self, PttError> {
let (chip, offset, active_low) = parse_gpio_spec(spec)?;
#[cfg(feature = "gpio")]
#[cfg(all(target_os = "linux", feature = "gpio"))]
{
let line = CdevLine::request(&chip, offset)?;
let mut ctrl = Self::with_line(Box::new(line), active_low);
ctrl.release_ptt()?; // ensure the physical line starts in the released state
Ok(ctrl)
}
#[cfg(not(feature = "gpio"))]
#[cfg(not(all(target_os = "linux", feature = "gpio")))]
{
let _ = (chip, offset, active_low);
Err(PttError::Serial(
Expand Down Expand Up @@ -109,13 +109,13 @@ impl PttController for GpioPtt {
}
}

#[cfg(feature = "gpio")]
#[cfg(all(target_os = "linux", feature = "gpio"))]
struct CdevLine {
req: gpiocdev::Request,
offset: u32,
}

#[cfg(feature = "gpio")]
#[cfg(all(target_os = "linux", feature = "gpio"))]
impl CdevLine {
fn request(chip: &str, offset: u32) -> Result<Self, PttError> {
let chip_path = if chip.starts_with('/') {
Expand All @@ -133,7 +133,7 @@ impl CdevLine {
}
}

#[cfg(feature = "gpio")]
#[cfg(all(target_os = "linux", feature = "gpio"))]
impl PttLine for CdevLine {
fn set(&mut self, high: bool) -> Result<(), PttError> {
let v = if high {
Expand Down
Loading
Loading