Skip to content

Commit 5c4b9e8

Browse files
secupclaude
andcommitted
feat(arq): keepalive ACK DEFAULT-ON — universal channel_busy_query_ makes it collision-safe on GUI + TNC (fixes the default-on blocker)
Connection gains channel_busy_query_ (mirrors setTxActiveProvider), wired from the modem by both app.cpp (GUI) and ultra_tnc.cpp (headless). Keepalive self-gates on channel-busy → no listen-before-ACK dependency. UltraTncSimAudio (broke at 264s timeout with default-on before) now PASSES 58.8s; ctest 86/86. 8s stall-cap unchanged on the rig. Caps the 44s marginal-SNR stall at 8s everywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019btwFxDt7D19SZSqPYvZnk
1 parent ab959e8 commit 5c4b9e8

6 files changed

Lines changed: 63 additions & 13 deletions

File tree

docs/CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,25 @@ Verified: ctest 85/85; good@20 PASS 1600 bps.
8484

8585
---
8686

87+
## 2026-07-14 — feat(arq): keepalive ACK now DEFAULT-ON — universal channel-busy gate makes it collision-safe on GUI AND TNC
88+
89+
The TNC-path blocker is fixed. The keepalive's collision-safety no longer
90+
depends on the GUI-only listen-before-ACK: the Connection gained a
91+
channel_busy_query_ (mirrors setTxActiveProvider) that BOTH the GUI (app.cpp)
92+
and the headless TNC (ultra_tnc.cpp) wire from the modem
93+
(channelBusyForTx || burstAirSamplesRemaining>0). The keepalive checks it and
94+
skips while a burst is arriving — self-gating, universal. So the 8 s keepalive
95+
is now DEFAULT-ON (opt-out ULTRA_KEEPALIVE_ACK=0).
96+
97+
Verified: UltraTncSimAudio — which default-on BROKE before the gate (264 s
98+
timeout) — now PASSES (58.8 s) with default-on. Full ctest 86/86. The 8 s
99+
stall-cap (rig-proven F290/F291) is unchanged on the GUI: after a rejected
100+
burst the channel is idle → busy=false → keepalive fires at 8 s exactly as
101+
before. Answers the operator's "no ACK at SNR 25": the 44 s marginal-SNR stall
102+
is now capped at 8 s everywhere.
103+
104+
---
105+
87106
## 2026-07-14 — feat(arq): keepalive ACK v2 (8 s) — rig-PROVEN (stall 44→8 s, no collision), default-off blocked by TNC listen-before-ACK gap
88107

89108
Direct silence-gap measurement (epoch-independent, unlike the goodput A/B)

src/gui/app.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,13 @@ App::App(const Options& opts) : options_(opts), simulation_enabled_(opts.enable_
542542
protocol_.setTxActiveProvider(
543543
[this] { return tx_in_progress_.load(std::memory_order_relaxed); });
544544

545+
// Keepalive ACK universal busy gate: don't re-emit an ACK while a burst is
546+
// arriving (would collide). Mirrors the GUI's own listen-before-ACK signal.
547+
protocol_.setChannelBusyQuery([this] {
548+
return modem_.channelBusyForTx() ||
549+
modem_.burstAirSamplesRemaining() > 0;
550+
});
551+
545552
// Set up status callback to show codeword progress in RX log
546553
modem_.setStatusCallback([this](const std::string& status) {
547554
enqueueOperatorLogLine(status);

src/protocol/connection.cpp

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4424,20 +4424,18 @@ void Connection::tick(uint32_t elapsed_ms) {
44244424
// routes through listen-before-ACK; the sender resends holes on any
44254425
// tone-burst ACK (turn boundary). v2 (filed): channel-gated shorter
44264426
// threshold for a bigger saving.
4427-
// ULTRA_KEEPALIVE_ACK, DEFAULT-OFF. On the rig/GUI the 8 s keepalive
4428-
// is mechanism-proven (F290/F291: max ACK-silence gap capped at
4429-
// exactly 8.0 s vs OFF's 36.7 s) and collision-free — because it
4430-
// routes through the GUI's listen-before-ACK (app.cpp), so a
4431-
// mid-burst fire DEFERS. BLOCKER for default-on: the HEADLESS TNC
4432-
// path (ultra_tnc) has NO listen-before-ACK gating, so default-on
4433-
// broke UltraTncSimAudio (keepalive TX'd over inbound bursts →
4434-
// collision → timeout). Fix before flipping: move the channel-clear
4435-
// gating into the connection/ARQ (universal) rather than the GUI
4436-
// app layer, so the keepalive is safe on TNC too. Until then:
4437-
// opt-in on the GUI/rig where the gating exists.
4427+
// KEEPALIVE ACK, DEFAULT-ON 2026-07-14 (opt-out ULTRA_KEEPALIVE_ACK=0).
4428+
// 8 s keepalive: rig-proven (F290/F291 max ACK-silence gap capped at
4429+
// exactly 8.0 s vs OFF's 36.7 s, reproducible, cwfails normal).
4430+
// UNIVERSAL collision-safety via channel_busy_query_ (below) — the
4431+
// keepalive checks "is a burst arriving?" itself and skips if busy,
4432+
// so it no longer depends on the GUI-only listen-before-ACK (which
4433+
// had blocked default-on: the headless TNC lacked it). Both the GUI
4434+
// (app.cpp) and the TNC (ultra_tnc.cpp) wire the query from the modem.
44384435
static const bool keepalive_ack_enabled = [] {
44394436
const char* e = std::getenv("ULTRA_KEEPALIVE_ACK");
4440-
return e != nullptr && e[0] != '\0' && e[0] != '0';
4437+
if (!e || e[0] == '\0') return true; // default-ON
4438+
return !(e[0] == '0' && e[1] == '\0'); // "0" opts out
44414439
}();
44424440
// Threshold env-tunable (ULTRA_KEEPALIVE_ACK_MS, default 25000).
44434441
// The keepalive routes through listen-before-ACK (defers on channel
@@ -4453,7 +4451,11 @@ void Connection::tick(uint32_t elapsed_ms) {
44534451
}
44544452
return 8000u; // v2 (rig-proven): caps stall at 8 s, no collision
44554453
}();
4456-
if (keepalive_ack_enabled) {
4454+
// Emit only when the channel is NOT busy (no inbound burst) — the
4455+
// universal collision guard. If unwired (tests), fall back to
4456+
// threshold-only (safe when threshold > max burst airtime).
4457+
if (keepalive_ack_enabled &&
4458+
(!channel_busy_query_ || !channel_busy_query_())) {
44574459
arq_.keepaliveAckIfStalled(keepalive_ack_ms);
44584460
}
44594461

src/protocol/connection.hpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,17 @@ class Connection {
233233
tx_active_provider_ = std::move(provider);
234234
}
235235

236+
// Channel-busy query (keepalive ACK universal gating, 2026-07-14): returns
237+
// true when a burst is currently arriving / the channel is busy. The
238+
// keepalive checks this BEFORE emitting so it never TX's over an inbound
239+
// burst — replacing the GUI-only listen-before-ACK dependency that blocked
240+
// default-on (the headless TNC lacked it). Both the GUI and the TNC set it
241+
// from the modem (channelBusyForTx || burstAirSamplesRemaining>0). Unwired
242+
// (tests) => nullptr => keepalive falls back to threshold-only safety.
243+
void setChannelBusyQuery(std::function<bool()> query) {
244+
channel_busy_query_ = std::move(query);
245+
}
246+
236247
void setTransmitToneBurstAckCallback(TransmitToneBurstAckCallback cb) {
237248
on_transmit_tone_burst_ack_ = std::move(cb);
238249
}
@@ -1292,6 +1303,7 @@ class Connection {
12921303
TransmitInfoCallback on_transmit_info_;
12931304
TransmitToneBurstAckCallback on_transmit_tone_burst_ack_;
12941305
std::function<bool()> tx_active_provider_; // BUG-MC-RETRY-SPURIOUS: see setter
1306+
std::function<bool()> channel_busy_query_; // keepalive universal busy gate
12951307
DriveAdvisoryCallback on_drive_advisory_; // software-ALC sender-side hook
12961308
ArmToneBurstAckMonitorCallback on_arm_tone_burst_ack_monitor_;
12971309
ConnectedCallback on_connected_;

src/protocol/protocol_engine.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,10 @@ class ProtocolEngine {
286286
connection_.setTxActiveProvider(std::move(provider));
287287
}
288288

289+
void setChannelBusyQuery(std::function<bool()> query) {
290+
connection_.setChannelBusyQuery(std::move(query));
291+
}
292+
289293
// Half-duplex INTERACTIVE (bidirectional) data path — the TNC / Winlink-B2F
290294
// case where both stations alternately transmit. Keeps the ISS/IRS turn gate
291295
// on burst sends so the directions serialize instead of colliding.

tools/ultra_tnc.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,12 @@ class UltraTNCStation {
466466
// to a full chirp+LTS anchor so the peer can re-acquire our timing — the
467467
// fix for the half-duplex B2F stall (BUG-TNC-B2F-001). The yielding peer
468468
// already arms expectFullOFDMAnchorOnce() via the TURNOVER it sent.
469+
// Keepalive ACK universal busy gate (the headless TNC has no
470+
// listen-before-ACK, so the keepalive must self-gate on channel-busy).
471+
engine_.setChannelBusyQuery([this] {
472+
return modem_.channelBusyForTx() ||
473+
modem_.burstAirSamplesRemaining() > 0;
474+
});
469475
engine_.setDataTurnAcquiredCallback([this]() {
470476
modem_.forceNextFrameFullPreamble();
471477
});

0 commit comments

Comments
 (0)