From 62c7c98494116927d7636b0cd96d538d52ca60b9 Mon Sep 17 00:00:00 2001 From: Frank0x01 Date: Mon, 13 Jul 2026 12:32:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(qso-answerer):=20D-CALLER-018/016/019=20?= =?UTF-8?q?=E2=80=94=20abort=20hard-stop,=20faster=20re-engage,=20dead-cod?= =?UTF-8?q?e=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Abort must be a hard stop per the Captain's stated requirement: once clicked, nothing may re-engage TX until the operator explicitly requests it again. AbortAsync previously no-opped whenever _state was already Idle, so a pending CQ target or jump-in armed by AnswerCqAsync/ EngageAtAsync (which fire independently of _state while Idle) could not be cancelled — it fired regardless, up to ~30s later, no matter how many times Abort was clicked (14 no-op clicks observed in logs/openswfz-20260712T211150Z.log). AbortAsync now unconditionally clears the pending-target/jump-in fields before its Idle guard. Also raises MaxLateStartSeconds from 1.5s to 2.0s (D-CALLER-016): the 1.5s threshold left almost no realistic click budget after decode processing, so nearly every manual engagement missed its window and was deferred a full 30s. 2.0s still leaves a 0.36s safety margin against FT8 overrun while catching realistic latency. D-CALLER-019 removes web/js/main.js's redundant legacy CQ-row dblclick handler (predates D-CALLER-012's engage-decode handler, which already reproduces it plus does abort-first) — it always lost the race and produced a guaranteed spurious 409 on every double-click. Carried its stray-selection cleanup forward into the remaining handler. Verified live against a real isolated daemon over its real HTTP/WebSocket API (no mocks) — qa/d-caller-018-abort-hard-stop-live-verify/ — including a deliberate control run with the fix reverted that reproduces the exact production failure, and a real-browser double-click check proving D-CALLER-019 leaves exactly one outbound POST. Full suite 1176/1176. Co-Authored-By: Claude Sonnet 5 --- ...rer-abort-hard-stop-and-reengage-timing.md | 301 ++++++++ .../README.md | 51 ++ .../2026-07-12T221248Z-d3eff84.md | 51 ++ .../2026-07-12T221403Z-d3eff84.md | 177 +++++ .../2026-07-12T221518Z-d3eff84.md | 51 ++ .../live_verify_abort_hardstop.py | 675 ++++++++++++++++++ src/OpenWSFZ.Daemon/QsoAnswererService.cs | 32 +- .../QsoAnswererServiceTests.cs | 113 ++- web/js/main.js | 64 +- 9 files changed, 1456 insertions(+), 59 deletions(-) create mode 100644 dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md create mode 100644 qa/d-caller-018-abort-hard-stop-live-verify/README.md create mode 100644 qa/d-caller-018-abort-hard-stop-live-verify/live-reports/2026-07-12T221248Z-d3eff84.md create mode 100644 qa/d-caller-018-abort-hard-stop-live-verify/live-reports/2026-07-12T221403Z-d3eff84.md create mode 100644 qa/d-caller-018-abort-hard-stop-live-verify/live-reports/2026-07-12T221518Z-d3eff84.md create mode 100644 qa/d-caller-018-abort-hard-stop-live-verify/live_verify_abort_hardstop.py diff --git a/dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md b/dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md new file mode 100644 index 0000000..865290f --- /dev/null +++ b/dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md @@ -0,0 +1,301 @@ +# Handoff: D-CALLER-018/016/019 — Abort must be a hard stop; re-engagement is needlessly delayed 30 s + +**Date:** 2026-07-12 +**Prepared by:** QA engineer (analysis of `logs/openswfz-20260712T211150Z.log`, Captain report) +**Status:** Awaiting developer action +**Defect IDs:** D-CALLER-018 (critical), D-CALLER-016 (high), D-CALLER-019 (medium, cleanup) +**Severity:** Critical — the operator's Abort action does not reliably stop the transmitter, and once +that happens there is currently **no recourse**: the armed engagement fires regardless of how many +times Abort is clicked. This is a repeat of the abort-race family first seen in D-CALLER-009/010; this +occurrence is a new root cause, not a regression of the earlier fix. + +--- + +## 0. Captain's stated requirement (authoritative — design to this) + +> Abort just means stop all and everything, bring the application to an idle state. Do not engage TX +> again unless the operator decides otherwise. When double-clicking a QSO already engaged it should +> switch to resume the QSO. When double-clicking a new CQ it should engage on the new one. And, not to +> forget, when a CQ is double-clicked it should engage immediately at the very first opportunity instead +> of waiting 30 seconds before engaging. + +Three separate defects below implement this. Read D-CALLER-018 first — it is the one that actually +explains the reported "it engages after an abort" behaviour. + +--- + +## 1. Context + +### 1.1 What the log shows + +Across `logs/openswfz-20260712T211150Z.log`, the Answerer engaged CX1RL three times in a row, ~29–30 s +apart, despite the operator aborting every single time: + +``` +23:18:30.754 pending CQ target 'CX1RL' — answering at A phase +23:18:30.766 KeyDown — PTT asserted +23:18:31.779 TX abort requested (HTTP) — cancelling active session (partner: CX1RL, state: TxAnswer) +23:18:31.861 KeyUp — PTT released +23:18:31.878 aborted to Idle (was: TxAnswer, partner: CX1RL) +23:18:31.892 pending target 'CX1RL' — late start (1.9 s into window); deferring to next occurrence ← re-armed, same request +23:18:36.599–23:18:38.427 FOURTEEN POST /api/v1/tx/abort calls, all 200 OK, all no-ops (state already Idle) +23:19:00.776 pending CQ target 'CX1RL' — answering at A phase ← fires anyway, 29 s later +23:19:00.778 KeyDown — PTT asserted +``` + +The same pattern repeats for LU1DA (23:13:30→23:14:00) and ZL4TT (23:17:30). This is not one glitch; it +is the system working exactly as coded, which is the problem. + +### 1.2 Root cause chain + +- `POST /api/v1/tx/engage-decode` (D-CALLER-012, `WebApp.cs` line 1251) is a **double-click** gesture: + abort whatever is running, then dispatch the clicked row (`AnswerCqAsync` for a `CQ ...` row, + `EngageAtAsync` for a directed report/RR73/73 row). This is legitimate, explicit operator intent — + double-clicking a row is asking to engage it — and per the Captain's requirement above, it is correct + for it to arm a fresh target immediately, including re-answering the same partner if they're still + calling CQ. +- The problem is that the **dedicated Abort button** (`POST /api/v1/tx/abort` → `QsoAnswererService.AbortAsync`, + line 257) cannot cancel that armed target once `_state` has returned to `Idle`: + + ```csharp + public async Task AbortAsync(CancellationToken ct = default) + { + if (_state == QsoState.Idle) return; // ← everything below is skipped, INCLUDING clearing + // _pendingTargetCallsign / _jumpPartner + ... + } + ``` + + `_pendingTargetCallsign` (armed by `AnswerCqAsync`/`ArmPendingTarget`, line 387) and `_jumpPartner` + (armed by `EngageAtAsync`, line 229) are **independent of `_state`** — they are consulted at the top of + `HandleIdleAsync` (lines 564 and 632) specifically so they can fire *while* Idle. Only + `SafeAbortToIdleAsync` (line 1263) clears them, and that method only runs as part of a state + transition *out of* an active session — never when the service is already sitting Idle with a pending + target quietly waiting for its window. + + Net effect: once a target is armed, the Abort button is a no-op for the remainder of that ~60 s + window (the 14 clicks at 23:18:36–38 prove this) and the engagement **will** fire. There is currently + no operator action that can prevent it. This directly contradicts "do not engage TX again unless the + operator decides otherwise." + +### 1.3 Secondary finding: needless 30 s delay + +`MaxLateStartSeconds = 1.5` (`QsoAnswererService.cs` line 139, added by D-CALLER-013) leaves very little +room: an FT8 tone is 12.64 s in a 15 s window, so the true physical ceiling is `15 − 12.64 = 2.36 s`. +Decode processing alone consumes 0.4–0.7 s in this log before a click is even possible. Every +manually-armed engagement in this session — both the original clicks and the abort-triggered +re-engagements — landed at 1.9 s or 3.7 s into its window, both past the 1.5 s cutoff, and both were +deferred a full 30 s (phase alternates every 15 s, so the next *matching-phase* window is always two +cycles away, never one). This is the "always skips two cycles" / "waiting 30 seconds" complaint. + +### 1.4 Tertiary finding: duplicate double-click handlers (noise, not the root cause) + +Every CQ row in `web/js/main.js` carries **two** independent `dblclick` listeners: + +- Line 733 (TX-D01, pre-dates D-CALLER-012): calls `postTxAnswerCq(...)` directly. +- Line 847 (D-CALLER-012): calls `postTxEngageDecode(...)` — abort, then dispatch, and its own + `Case A` (WebApp.cs line 1318) already reproduces exactly what the line-733 handler does. + +A single double-click fires both. The line-733 call always loses the race and returns 409 (log lines +894/910, 360/376, etc.) — harmless, but it is dead code doubling every double-click's network traffic +and cluttering the log with a guaranteed spurious 409 on every single engagement. Worth removing while +in this code, but it is not what caused the reported behaviour — D-CALLER-012's own Step 1 abort is +what's logged as "TX abort requested (HTTP)" in every instance above, not a separate Abort-button click. + +--- + +## 2. Branch + +Create a new branch off `main`: `fix/d-caller-018-abort-hard-stop`. No branch of this name currently +exists (verified 2026-07-12) and this is not an amendment to any in-flight work — start clean from `main`. + +--- + +## 3. Actions + +### 3.1 — D-CALLER-018: `AbortAsync` must unconditionally clear armed targets (Critical) + +**File:** `src/OpenWSFZ.Daemon/QsoAnswererService.cs`, `AbortAsync` (currently lines 257–280). + +Move the pending-target/jump-in clear (the same fields `SafeAbortToIdleAsync` clears at lines +1274–1278) **above** the `Idle` guard, so it always runs regardless of `_state`: + +```csharp +public async Task AbortAsync(CancellationToken ct = default) +{ + // D-CALLER-018: unconditionally clear any armed-but-not-yet-fired pending target or + // jump-in, regardless of current _state. A target armed by AnswerCqAsync / EngageAtAsync / + // TryEngageExternal fires independently of _state (it is specifically checked while Idle — + // see HandleIdleAsync). Previously this method returned immediately whenever _state was + // already Idle, which meant an armed target could NOT be cancelled by the operator once the + // service returned to Idle — it fired regardless, up to ~30 s later, no matter how many times + // Abort was clicked. Abort must be an unconditional hard stop: nothing may re-engage after it + // until the operator explicitly requests it again. See + // dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md. + lock (_stateLock) + { + _pendingTargetCallsign = null; + _pendingTargetFrequencyHz = 0.0; + _pendingTargetIsAPhase = false; + _pendingTargetSetAt = default; + _jumpPartner = null; + } + + if (_state == QsoState.Idle) return; + + _logger.LogInformation( + "TX abort requested (HTTP) — cancelling active session (partner: {Partner}, state: {State}).", + _partner, _state); + + _operatorAbortRequested = true; + _txCts.Cancel(); + + try + { + await _pttController.KeyUpAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "KeyUpAsync threw during abort — ignoring."); + } +} +``` + +No change needed to `SafeAbortToIdleAsync` — its own clear is now redundant in the pure-Abort path but +still correct/necessary for the watchdog-timeout and retry-exhaustion paths that don't go through +`AbortAsync` at all. + +**Do not** add any additional "latch" or cool-down flag. `engage-decode`'s Step 1 also calls +`AbortAsync`, then Step 2 deliberately re-arms a fresh target — that re-arm is a *new*, explicit operator +gesture (the double-click itself) and must keep working exactly as today. The fix above only guarantees +that a *separate, later* Abort-button click always wins and leaves nothing armed — it does not prevent +`engage-decode`'s own Step 2 from arming within the same request. + +### 3.2 — D-CALLER-016: raise the late-start threshold (High) + +**File:** `src/OpenWSFZ.Daemon/QsoAnswererService.cs`, line 139. + +```csharp +/// +/// Maximum number of seconds into a 15-second FT8 window that TX may still be started. +/// An FT8 signal is 12.64 s; the true physical ceiling is 15 - 12.64 = 2.36 s — starting any +/// later overruns into the next window and can prevent the far station from decoding cleanly. +/// D-CALLER-013 originally set this to 1.5 s, leaving only ~0.8-1.0 s of realistic click budget +/// after decode processing (0.4-0.7 s observed) — in practice this meant almost every manual +/// click (openswfz-20260712T211150Z.log: 1.9 s and 3.7 s observed) missed its window and was +/// deferred a full 30 s (phase alternates every 15 s, so the next matching-phase window is +/// always two cycles away, never one — see D-CALLER-016). 2.0 s leaves a 0.36 s hard safety +/// margin against overrun, comfortably covering the ~50-90 ms KeyDown/device-open jitter observed +/// in production logs, while catching realistic decode-to-click latency instead of nearly always +/// missing it. +/// +private const double MaxLateStartSeconds = 2.0; +``` + +Be honest in the PR description and with the Captain: this reduces *how often* the 30 s deferral is hit, +it does not eliminate it. A click that lands beyond ~2.3 s into its window genuinely cannot start a +12.64 s FT8 transmission in that window without overrunning — that remaining wait is FT8 protocol +physics (alternating 15 s slots), not a bug, and no threshold tuning removes it. Update the existing +D-CALLER-013 tests (`QsoAnswererServiceTests.cs` lines ~2351–2521) that assert the exact +`MaxLateStartSeconds` boundary — they currently use 1.5 s and 5 s/0.5 s/0.2 s fixtures; adjust the +"late" fixtures to land clearly past 2.0 s (e.g. keep 5 s) and the "timely" fixtures to land clearly +under it (e.g. keep 0.5 s, 0.2 s — both still pass) so the tests remain meaningful rather than just +re-encoding whatever the new constant is. + +### 3.3 — D-CALLER-019: remove the redundant legacy dblclick handler (Medium, cleanup) + +**File:** `web/js/main.js`, lines 727–776 (the TX-D01 `.decode-cq` dblclick handler that calls +`postTxAnswerCq` directly). + +Remove this block entirely. `D-CALLER-012`'s handler (line 841 onward) already reproduces it exactly: +its `Case A` dispatch (`WebApp.cs` line 1318) parses a `CQ ...` message and calls the identical +`AnswerCqAsync`, and additionally handles the abort-first step this old handler never did. Keeping both +means every CQ-row double-click fires two competing HTTP requests, one of which is guaranteed to lose +and return 409 — pure noise that made this session's log harder to diagnose. Confirm no other code +depends on the `inFlight` guard or `cqCycleStartUtc` dataset attribute this block referenced (the D-CALLER-012 +handler reads `tr.dataset.cqCycleStartUtc` independently at line 855, so it is unaffected by the removal). + +`postTxAnswerCq` itself (`web/js/api.js`) should remain — it is still the function `engage-decode`'s +front-end awaits indirectly is not — check whether anything else in `main.js` calls `postTxAnswerCq` +directly before deleting its import; if nothing else does, remove the now-unused import too. + +--- + +## 4. Acceptance criteria + +**AC-1 (D-CALLER-018).** With a pending target armed (e.g. via `engage-decode`) and the service already +back at `Idle`, clicking Abort clears the pending target. Confirm: advance time/inject the next +matching-phase batch and assert `IPttController.KeyDownAsync` is **not** called. Add this as an explicit +unit test in `QsoAnswererServiceTests.cs` — arm via `AnswerCqAsync` with a late-start time so it doesn't +fire immediately, call `AbortAsync`, then push the correct-phase batch and assert no TX. + +**AC-2 (D-CALLER-018).** Same as AC-1 but for a jump-in target (`EngageAtAsync`) instead of a pending +CQ target. + +**AC-3 (D-CALLER-018).** Regression: `AbortAsync` while genuinely mid-TX still cancels the active +transmission exactly as before (existing `AbortAsync` tests must continue to pass unmodified). + +**AC-4 (D-CALLER-016).** A click landing ≤2.0 s into the correct-phase window fires TX in that same +window. A click landing >2.0 s is deferred to the next matching-phase window (~30 s later) — update +existing D-CALLER-013 tests per §3.2 rather than duplicating them. + +**AC-5 (D-CALLER-019).** Double-clicking a CQ row produces exactly one outbound POST +(`/api/v1/tx/engage-decode`), not two. Confirm in the Network tab or via a front-end test that +`postTxAnswerCq` is no longer wired to CQ-row `dblclick`. + +**AC-6 (end-to-end, matches the Captain's stated requirement — verify with the `verify` skill against a +real session, not just unit tests).** +- Abort while mid-QSO with any partner → service reaches Idle and stays there; no TX fires afterward + regardless of what CQs continue to be decoded, until the operator takes a new explicit action. +- Double-clicking the CQ row of the station currently engaged → resumes/re-engages that same partner + (no behavioural change required here beyond AC-4's timing fix — `engage-decode`'s existing dispatch + already does this correctly once 3.1/3.2 land). +- Double-clicking a different station's CQ row while mid-QSO → aborts the current session and engages + the new one (existing D-CALLER-012 behaviour, unchanged). +- None of the above engagements take the needless extra 15 s cycle that 3.2 fixes. + +**AC-7.** `dotnet build OpenWSFZ.slnx -c Release` — 0 errors, 0 warnings. + +**AC-8.** Full test suite — 0 failures (current baseline ≥948 passed per project state notes; new/updated +tests add to the count). + +--- + +## 5. Live verification requirement + +This defect was only caught by reading a real operating log, not by unit tests — the existing D-CALLER-013 +unit tests all passed while this was broken, because none of them exercised "Abort while a target is +armed but the service is already Idle." Before merge, run a real loopback session (VB-CABLE or equivalent, +per the D-CALLER-013/014 annex notes on audio isolation) reproducing the exact sequence from +§1.1 — engage, abort mid-TX, then hammer the Abort button — and attach the resulting log excerpt to the +PR showing no re-engagement occurs. Unit tests alone are not sufficient sign-off for this class of defect. + +--- + +## 6. References + +- Evidence log: `logs/openswfz-20260712T211150Z.log` — lines 887–1115 (CX1RL sequence, most complete), + 296–392 (LU1DA), 683–733 (ZL4TT) +- `src/OpenWSFZ.Daemon/QsoAnswererService.cs`: + - `AbortAsync` — lines 257–280 (fix target, §3.1) + - `MaxLateStartSeconds` — line 139 (fix target, §3.2) + - `SafeAbortToIdleAsync` pending/jump clear — lines 1271–1279 (pattern to replicate in §3.1) + - `ArmPendingTarget` — lines 387–412 + - Pending-target consumption — lines 632–712 + - Jump-in consumption — lines 564–629 +- `src/OpenWSFZ.Web/WebApp.cs`: + - `POST /api/v1/tx/abort` — lines 1101–1117 + - `POST /api/v1/tx/engage-decode` — lines 1251–1408 (Step 1 abort-and-poll at 1282–1303, Step 2 + dispatch at 1305–1396) +- `web/js/main.js`: + - TX-D01 legacy CQ-row dblclick handler — lines 727–776 (removal target, §3.3) + - D-CALLER-012 dblclick handler — lines 841–887 + - Dedicated Abort button handler — lines 1366–1385 (unaffected; the bug was server-side) +- `dev-tasks/2026-06-27-d-caller-013-late-tx-guard.md` — original `MaxLateStartSeconds` rationale + (1.5 s chosen without production click-latency data; this task supersedes that value) +- `dev-tasks/2026-06-26-d-caller-009-rearm-race.md`, `dev-tasks/2026-06-27-d-caller-010-dblclick-abort.md` + — prior occurrences of the same abort-race defect family, on the Caller side; different root cause, + same category of mistake (assuming an abort-adjacent action has fully settled when it hasn't, or that + "Idle" implies "nothing pending") +- `tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs` lines 2351–2521 — existing D-CALLER-013 + late-start tests to extend (§3.1 new tests) and adjust (§3.2) diff --git a/qa/d-caller-018-abort-hard-stop-live-verify/README.md b/qa/d-caller-018-abort-hard-stop-live-verify/README.md new file mode 100644 index 0000000..c56c4d8 --- /dev/null +++ b/qa/d-caller-018-abort-hard-stop-live-verify/README.md @@ -0,0 +1,51 @@ +# D-CALLER-018/016 abort hard-stop — live verification + +`live_verify_abort_hardstop.py` is the live, hardware-in-the-loop verification required by +`dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md` §5. It starts a real, +isolated `OpenWSFZ.Daemon` instance (own scratch port/config, never touches the Captain's real +config) and drives it over its real HTTP/WebSocket API — no `FakeTimeProvider`, no mocked +`IPttController` — reproducing the exact production sequence from +`logs/openswfz-20260712T211150Z.log` (arm a pending CQ target, let the late-start guard defer it, +hammer the dedicated Abort button while Idle, wait past the next matching-phase window, confirm no +re-engagement). + +Run it after `dotnet build OpenWSFZ.slnx -c Release`: + +``` +python qa/d-caller-018-abort-hard-stop-live-verify/live_verify_abort_hardstop.py +``` + +Requires a real audio output device (no virtual cable needed — `AudioOnlyPttController`'s CQ +transmission doesn't require a decode input). Exit 0 = PASS, 1 = FAIL, 2 = ENVIRONMENT-UNAVAILABLE +(report still written in that case). + +## `live-reports/` — what's in here + +- **`2026-07-12T221248Z-d3eff84.md` — PASS.** Run against the fix (`AbortAsync`'s unconditional + clear + `MaxLateStartSeconds = 2.0`). Both phases pass: A (sanity control — a timely arm fires a + real transmission, then Abort mid-TX stops it promptly — AC-3 live regression) and B (the actual + defect — a late-armed target is hammered with Abort while Idle, then never fires ~30 s later). +- **`2026-07-12T221403Z-d3eff84.md` — FAIL, deliberate control run.** Same script, run with + `QsoAnswererService.cs`'s fix temporarily reverted (`git stash` of just that file) to prove the + script actually catches the regression rather than false-passing. Phase B fails exactly as the + production log did: `Q9BUG` fires ~29 s after the 14-click Abort hammer + (`"QsoAnswererService: pending CQ target 'Q9BUG' at 1600 Hz - answering at A phase."` in the log + tail). The fix was restored and rebuilt immediately after this run. +- **`2026-07-12T221518Z-d3eff84.md` — PASS.** Final confirmation run with the fix restored, kept + as the PR's attached evidence per dev-task §5. + +(An earlier run, `2026-07-12T221048Z-d3eff84.md`, failed on a script bug — a regex requiring the +literal em dash `—` that the daemon's console sink silently downgrades to a plain hyphen on +Windows, the same encoding gotcha `qa/tx-keying-live-verify/live_verify_keying.py` already +documents — not a product bug. Fixed in the script and the report was deleted to avoid confusion.) + +## AC-5 (D-CALLER-019 front-end cleanup) — verified separately, not by this script + +The `web/js/main.js` dblclick-handler cleanup (removing the redundant legacy `postTxAnswerCq` +listener) was verified live in a real headless Chromium browser against a real running daemon: +loaded the real `index.html`, injected a synthetic `decode` WebSocket frame so the real, +unmodified `handleDecodes()` created a real ``, then performed a real +browser double-click on it. Result: exactly one outbound `POST /api/v1/tx/engage-decode`, zero +`POST /api/v1/tx/answer-cq` calls, zero console errors. That one-off Playwright driver was not +committed (this repo has no Node/Playwright dependency anywhere else) — the result is recorded +here for the record; see the PR description for the raw evidence. diff --git a/qa/d-caller-018-abort-hard-stop-live-verify/live-reports/2026-07-12T221248Z-d3eff84.md b/qa/d-caller-018-abort-hard-stop-live-verify/live-reports/2026-07-12T221248Z-d3eff84.md new file mode 100644 index 0000000..ac45c26 --- /dev/null +++ b/qa/d-caller-018-abort-hard-stop-live-verify/live-reports/2026-07-12T221248Z-d3eff84.md @@ -0,0 +1,51 @@ +# D-CALLER-018/016 — Abort hard-stop live verification + +- **Run at (UTC):** 2026-07-12T22:12:48.012619+00:00 +- **Git commit:** `d3eff84` (branch `fix/d-caller-018-abort-hard-stop`) +- **Script:** `qa/d-caller-018-abort-hard-stop-live-verify/live_verify_abort_hardstop.py` +- **Dev-task:** `dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md` §5 + +## WebSocket `txState` events received (UTC wall clock) + +| # | Wall-clock (UTC) | state | partner | keying | +|---|---|---|---|---| +| 1 | `2026-07-12T22:12:00.171+00:00` | `TxAnswer` | `Q9CTRL` | `False` | +| 2 | `2026-07-12T22:12:00.179+00:00` | `TxAnswer` | `Q9CTRL` | `True` | +| 3 | `2026-07-12T22:12:00.294+00:00` | `TxAnswer` | `Q9CTRL` | `False` | +| 4 | `2026-07-12T22:12:00.297+00:00` | `Idle` | `None` | `False` | + +## Result + +**PASS** + +## Notes + +Binary under test: `D:\Projects\claude\OpenWSFZ\src\OpenWSFZ.Daemon\bin\Release\net10.0\OpenWSFZ.Daemon.exe` +Binary last-write time (UTC): `2026-07-12T22:00:17.718254+00:00` +QsoAnswererService.cs last-write time (UTC): `2026-07-12T22:00:15.132822+00:00` +Daemon process start time (UTC): `2026-07-12T22:11:49.158859+00:00` +Rebuild check: compiled binary postdates the source edits = `True`; process started after binary was built = `True` + +### Phase A — sanity control (timely arm) + AC-3 live regression (abort mid-TX) + +Arming `Q9CTRL` at `2026-07-12T22:12:00Z` — 0.15 s into window `2026-07-12T22:12:00Z` (timely, ≤ 2.0 s) — expect immediate fire. +POST /api/v1/tx/answer-cq (Q9CTRL) → HTTP 200 +Real `keying: true` observed for `Q9CTRL`: `True` +POST /api/v1/tx/abort (mid-TX, at `2026-07-12T22:12:00Z`) → HTTP 200 +`keying: false` observed after mid-TX abort: `True` +GET /api/v1/tx/status after Phase A abort: `{'state': 'Idle', 'partner': None, 'autoAnswerEnabled': False, 'role': 'answerer', 'callerPartnerSelect': 'First', 'keying': False}` +**Phase A result: PASS** + +### Phase B — D-CALLER-018 repro: hammer Abort while Idle with a deferred target + +Arming `Q9BUG` at `2026-07-12T22:12:22Z` — 7.00 s into window `2026-07-12T22:12:15Z` (late, > 2.0 s) — expect deferral, no fire. +POST /api/v1/tx/answer-cq (Q9BUG) → HTTP 200 +Late-start deferral log line found for `Q9BUG`: `True` (7.0 s into window) +No transmission for `Q9BUG` fired on the first (late) window: `True` +Hammered POST /api/v1/tx/abort x14 while Idle (2026-07-12T22:12:23Z .. 2026-07-12T22:12:24Z) — statuses: [200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200] +Waiting until `2026-07-12T22:12:48Z` (next matching-phase window `2026-07-12T22:12:45Z` + 3 s buffer) to confirm no re-engagement... +GET /api/v1/tx/status after wait: `{'state': 'Idle', 'partner': None, 'autoAnswerEnabled': False, 'role': 'answerer', 'callerPartnerSelect': 'First', 'keying': False}` +'answering at ... phase' log line ever appeared for `Q9BUG` (would prove re-engagement): `False` +No WS txState event names `Q9BUG` as partner after the abort hammer: `True` +**Phase B result: PASS** + diff --git a/qa/d-caller-018-abort-hard-stop-live-verify/live-reports/2026-07-12T221403Z-d3eff84.md b/qa/d-caller-018-abort-hard-stop-live-verify/live-reports/2026-07-12T221403Z-d3eff84.md new file mode 100644 index 0000000..bd4e1dd --- /dev/null +++ b/qa/d-caller-018-abort-hard-stop-live-verify/live-reports/2026-07-12T221403Z-d3eff84.md @@ -0,0 +1,177 @@ +# D-CALLER-018/016 — Abort hard-stop live verification + +- **Run at (UTC):** 2026-07-12T22:14:03.027221+00:00 +- **Git commit:** `d3eff84` (branch `fix/d-caller-018-abort-hard-stop`) +- **Script:** `qa/d-caller-018-abort-hard-stop-live-verify/live_verify_abort_hardstop.py` +- **Dev-task:** `dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md` §5 + +## WebSocket `txState` events received (UTC wall clock) + +| # | Wall-clock (UTC) | state | partner | keying | +|---|---|---|---|---| +| 1 | `2026-07-12T22:13:15.171+00:00` | `TxAnswer` | `Q9CTRL` | `False` | +| 2 | `2026-07-12T22:13:15.177+00:00` | `TxAnswer` | `Q9CTRL` | `True` | +| 3 | `2026-07-12T22:13:15.296+00:00` | `TxAnswer` | `Q9CTRL` | `False` | +| 4 | `2026-07-12T22:13:15.298+00:00` | `Idle` | `None` | `False` | +| 5 | `2026-07-12T22:14:00.189+00:00` | `TxAnswer` | `Q9BUG` | `False` | +| 6 | `2026-07-12T22:14:00.192+00:00` | `TxAnswer` | `Q9BUG` | `True` | + +## Result + +**FAIL** + +## Notes + +Binary under test: `D:\Projects\claude\OpenWSFZ\src\OpenWSFZ.Daemon\bin\Release\net10.0\OpenWSFZ.Daemon.exe` +Binary last-write time (UTC): `2026-07-12T22:12:57.964266+00:00` +QsoAnswererService.cs last-write time (UTC): `2026-07-12T22:12:52.960224+00:00` +Daemon process start time (UTC): `2026-07-12T22:13:06.356628+00:00` +Rebuild check: compiled binary postdates the source edits = `True`; process started after binary was built = `True` + +### Phase A — sanity control (timely arm) + AC-3 live regression (abort mid-TX) + +Arming `Q9CTRL` at `2026-07-12T22:13:15Z` — 0.15 s into window `2026-07-12T22:13:15Z` (timely, ≤ 2.0 s) — expect immediate fire. +POST /api/v1/tx/answer-cq (Q9CTRL) → HTTP 200 +Real `keying: true` observed for `Q9CTRL`: `True` +POST /api/v1/tx/abort (mid-TX, at `2026-07-12T22:13:15Z`) → HTTP 200 +`keying: false` observed after mid-TX abort: `True` +GET /api/v1/tx/status after Phase A abort: `{'state': 'Idle', 'partner': None, 'autoAnswerEnabled': False, 'role': 'answerer', 'callerPartnerSelect': 'First', 'keying': False}` +**Phase A result: PASS** + +### Phase B — D-CALLER-018 repro: hammer Abort while Idle with a deferred target + +Arming `Q9BUG` at `2026-07-12T22:13:37Z` — 7.00 s into window `2026-07-12T22:13:30Z` (late, > 2.0 s) — expect deferral, no fire. +POST /api/v1/tx/answer-cq (Q9BUG) → HTTP 200 +Late-start deferral log line found for `Q9BUG`: `True` (7.0 s into window) +No transmission for `Q9BUG` fired on the first (late) window: `True` +Hammered POST /api/v1/tx/abort x14 while Idle (2026-07-12T22:13:38Z .. 2026-07-12T22:13:39Z) — statuses: [200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200] +Waiting until `2026-07-12T22:14:03Z` (next matching-phase window `2026-07-12T22:14:00Z` + 3 s buffer) to confirm no re-engagement... +GET /api/v1/tx/status after wait: `{'state': 'TxAnswer', 'partner': 'Q9BUG', 'autoAnswerEnabled': False, 'role': 'answerer', 'callerPartnerSelect': 'First', 'keying': True}` +'answering at ... phase' log line ever appeared for `Q9BUG` (would prove re-engagement): `True` +No WS txState event names `Q9BUG` as partner after the abort hammer: `False` +**Phase B result: FAIL** + + +**Daemon log tail (last 120 lines) — diagnostic aid on failure:** +``` +[00:13:38 INF] Writing value of type 'TxStatusResponse' as Json. +[00:13:38 INF] Executed endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:38 INF] Request finished HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - 200 null application/json; charset=utf-8 1.5407ms +[00:13:38 DBG] Connection id "0HNN0CB02PM1A", Request id "0HNN0CB02PM1A:00000001": started reading request body. +[00:13:38 DBG] Connection id "0HNN0CB02PM1A", Request id "0HNN0CB02PM1A:00000001": done reading request body. +[00:13:38 DBG] Connection id "0HNN0CB02PM1A" disconnecting. +[00:13:38 DBG] Connection id "0HNN0CB02PM1A" stopped. +[00:13:38 DBG] Connection id "0HNN0CB02PM1A" sending FIN because: "The Socket transport's send loop completed gracefully." +[00:13:38 DBG] Connection id "0HNN0CB02PM1B" accepted. +[00:13:38 DBG] Connection id "0HNN0CB02PM1B" started. +[00:13:38 INF] Request starting HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - application/json 2 +[00:13:38 DBG] 1 candidate(s) found for the request path '/api/v1/tx/abort' +[00:13:38 DBG] Request matched endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:38 DBG] Static files was skipped as the request already matched an endpoint. +[00:13:38 INF] Executing endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:38 INF] Setting HTTP status code 200. +[00:13:38 INF] Writing value of type 'TxStatusResponse' as Json. +[00:13:38 INF] Executed endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:38 INF] Request finished HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - 200 null application/json; charset=utf-8 1.012ms +[00:13:38 DBG] Connection id "0HNN0CB02PM1B", Request id "0HNN0CB02PM1B:00000001": started reading request body. +[00:13:38 DBG] Connection id "0HNN0CB02PM1B", Request id "0HNN0CB02PM1B:00000001": done reading request body. +[00:13:38 DBG] Connection id "0HNN0CB02PM1B" disconnecting. +[00:13:38 DBG] Connection id "0HNN0CB02PM1B" stopped. +[00:13:38 DBG] Connection id "0HNN0CB02PM1B" sending FIN because: "The Socket transport's send loop completed gracefully." +[00:13:39 DBG] Connection id "0HNN0CB02PM1C" accepted. +[00:13:39 DBG] Connection id "0HNN0CB02PM1C" started. +[00:13:39 INF] Request starting HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - application/json 2 +[00:13:39 DBG] 1 candidate(s) found for the request path '/api/v1/tx/abort' +[00:13:39 DBG] Request matched endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 DBG] Static files was skipped as the request already matched an endpoint. +[00:13:39 INF] Executing endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 INF] Setting HTTP status code 200. +[00:13:39 INF] Writing value of type 'TxStatusResponse' as Json. +[00:13:39 INF] Executed endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 INF] Request finished HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - 200 null application/json; charset=utf-8 1.1806ms +[00:13:39 DBG] Connection id "0HNN0CB02PM1C", Request id "0HNN0CB02PM1C:00000001": started reading request body. +[00:13:39 DBG] Connection id "0HNN0CB02PM1C", Request id "0HNN0CB02PM1C:00000001": done reading request body. +[00:13:39 DBG] Connection id "0HNN0CB02PM1C" disconnecting. +[00:13:39 DBG] Connection id "0HNN0CB02PM1C" stopped. +[00:13:39 DBG] Connection id "0HNN0CB02PM1C" sending FIN because: "The Socket transport's send loop completed gracefully." +[00:13:39 DBG] Connection id "0HNN0CB02PM1D" accepted. +[00:13:39 DBG] Connection id "0HNN0CB02PM1D" started. +[00:13:39 INF] Request starting HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - application/json 2 +[00:13:39 DBG] 1 candidate(s) found for the request path '/api/v1/tx/abort' +[00:13:39 DBG] Request matched endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 DBG] Static files was skipped as the request already matched an endpoint. +[00:13:39 INF] Executing endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 INF] Setting HTTP status code 200. +[00:13:39 INF] Writing value of type 'TxStatusResponse' as Json. +[00:13:39 INF] Executed endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 INF] Request finished HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - 200 null application/json; charset=utf-8 1.3044ms +[00:13:39 DBG] Connection id "0HNN0CB02PM1D", Request id "0HNN0CB02PM1D:00000001": started reading request body. +[00:13:39 DBG] Connection id "0HNN0CB02PM1D", Request id "0HNN0CB02PM1D:00000001": done reading request body. +[00:13:39 DBG] Connection id "0HNN0CB02PM1D" disconnecting. +[00:13:39 DBG] Connection id "0HNN0CB02PM1D" stopped. +[00:13:39 DBG] Connection id "0HNN0CB02PM1D" sending FIN because: "The Socket transport's send loop completed gracefully." +[00:13:39 DBG] Connection id "0HNN0CB02PM1E" accepted. +[00:13:39 DBG] Connection id "0HNN0CB02PM1E" started. +[00:13:39 INF] Request starting HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - application/json 2 +[00:13:39 DBG] 1 candidate(s) found for the request path '/api/v1/tx/abort' +[00:13:39 DBG] Request matched endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 DBG] Static files was skipped as the request already matched an endpoint. +[00:13:39 INF] Executing endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 INF] Setting HTTP status code 200. +[00:13:39 INF] Writing value of type 'TxStatusResponse' as Json. +[00:13:39 INF] Executed endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 INF] Request finished HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - 200 null application/json; charset=utf-8 1.3724ms +[00:13:39 DBG] Connection id "0HNN0CB02PM1E", Request id "0HNN0CB02PM1E:00000001": started reading request body. +[00:13:39 DBG] Connection id "0HNN0CB02PM1E", Request id "0HNN0CB02PM1E:00000001": done reading request body. +[00:13:39 DBG] Connection id "0HNN0CB02PM1E" disconnecting. +[00:13:39 DBG] Connection id "0HNN0CB02PM1E" stopped. +[00:13:39 DBG] Connection id "0HNN0CB02PM1E" sending FIN because: "The Socket transport's send loop completed gracefully." +[00:13:39 DBG] Connection id "0HNN0CB02PM1F" accepted. +[00:13:39 DBG] Connection id "0HNN0CB02PM1F" started. +[00:13:39 INF] Request starting HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - application/json 2 +[00:13:39 DBG] 1 candidate(s) found for the request path '/api/v1/tx/abort' +[00:13:39 DBG] Request matched endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 DBG] Static files was skipped as the request already matched an endpoint. +[00:13:39 INF] Executing endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 INF] Setting HTTP status code 200. +[00:13:39 INF] Writing value of type 'TxStatusResponse' as Json. +[00:13:39 INF] Executed endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 INF] Request finished HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - 200 null application/json; charset=utf-8 0.991ms +[00:13:39 DBG] Connection id "0HNN0CB02PM1F", Request id "0HNN0CB02PM1F:00000001": started reading request body. +[00:13:39 DBG] Connection id "0HNN0CB02PM1F", Request id "0HNN0CB02PM1F:00000001": done reading request body. +[00:13:39 DBG] Connection id "0HNN0CB02PM1F" disconnecting. +[00:13:39 DBG] Connection id "0HNN0CB02PM1F" stopped. +[00:13:39 DBG] Connection id "0HNN0CB02PM1F" sending FIN because: "The Socket transport's send loop completed gracefully." +[00:13:39 DBG] Connection id "0HNN0CB02PM1G" accepted. +[00:13:39 DBG] Connection id "0HNN0CB02PM1G" started. +[00:13:39 INF] Request starting HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - application/json 2 +[00:13:39 DBG] 1 candidate(s) found for the request path '/api/v1/tx/abort' +[00:13:39 DBG] Request matched endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 DBG] Static files was skipped as the request already matched an endpoint. +[00:13:39 INF] Executing endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 INF] Setting HTTP status code 200. +[00:13:39 INF] Writing value of type 'TxStatusResponse' as Json. +[00:13:39 INF] Executed endpoint 'HTTP: POST /api/v1/tx/abort' +[00:13:39 INF] Request finished HTTP/1.1 POST http://127.0.0.1:18768/api/v1/tx/abort - 200 null application/json; charset=utf-8 1.1347ms +[00:13:39 DBG] Connection id "0HNN0CB02PM1G", Request id "0HNN0CB02PM1G:00000001": started reading request body. +[00:13:39 DBG] Connection id "0HNN0CB02PM1G", Request id "0HNN0CB02PM1G:00000001": done reading request body. +[00:13:39 DBG] Connection id "0HNN0CB02PM1G" disconnecting. +[00:13:39 DBG] Connection id "0HNN0CB02PM1G" stopped. +[00:13:39 DBG] Connection id "0HNN0CB02PM1G" sending FIN because: "The Socket transport's send loop completed gracefully." +[00:13:41 INF] Heartbeat: captureActive=True, audioActive=True, dataFlowing=True +[00:13:45 DBG] Window emitted (180000 samples, cycle 22:13:30). +[00:13:45 INF] Cycle skipped - RMS 0.000E+000 is below silence guard (threshold 1.000E-006). +[00:13:46 INF] Heartbeat: captureActive=True, audioActive=True, dataFlowing=True +[00:13:51 INF] Heartbeat: captureActive=True, audioActive=True, dataFlowing=True +[00:13:56 INF] Heartbeat: captureActive=True, audioActive=True, dataFlowing=True +[00:14:00 DBG] Window emitted (180000 samples, cycle 22:13:45). +[00:14:00 INF] Cycle skipped - RMS 0.000E+000 is below silence guard (threshold 1.000E-006). +[00:14:00 INF] QsoAnswererService: pending CQ target 'Q9BUG' at 1600 Hz - answering at A phase. +[00:14:00 INF] QsoAnswererService: watchdog armed for 4 minutes. +[00:14:00 DBG] QsoAnswererService: state  TxAnswer (partner: Q9BUG). +[00:14:00 INF] QsoAnswererService: TX  "Q9BUG Q1OFZ JO33" at 1600 Hz. +[00:14:00 DBG] TX audio loaded: 606720 samples (12640 ms). +[00:14:00 INF] TX KeyDown - starting playback on device '{0.0.0.00000000}.{a12483ac-beb4-484c-999b-d79c8f932997}' (606720 samples). +[00:14:00 DBG] TX: opened output device '{0.0.0.00000000}.{a12483ac-beb4-484c-999b-d79c8f932997}'. +[00:14:01 INF] Heartbeat: captureActive=True, audioActive=True, dataFlowing=True +``` diff --git a/qa/d-caller-018-abort-hard-stop-live-verify/live-reports/2026-07-12T221518Z-d3eff84.md b/qa/d-caller-018-abort-hard-stop-live-verify/live-reports/2026-07-12T221518Z-d3eff84.md new file mode 100644 index 0000000..0787ae9 --- /dev/null +++ b/qa/d-caller-018-abort-hard-stop-live-verify/live-reports/2026-07-12T221518Z-d3eff84.md @@ -0,0 +1,51 @@ +# D-CALLER-018/016 — Abort hard-stop live verification + +- **Run at (UTC):** 2026-07-12T22:15:18.015831+00:00 +- **Git commit:** `d3eff84` (branch `fix/d-caller-018-abort-hard-stop`) +- **Script:** `qa/d-caller-018-abort-hard-stop-live-verify/live_verify_abort_hardstop.py` +- **Dev-task:** `dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md` §5 + +## WebSocket `txState` events received (UTC wall clock) + +| # | Wall-clock (UTC) | state | partner | keying | +|---|---|---|---|---| +| 1 | `2026-07-12T22:14:30.170+00:00` | `TxAnswer` | `Q9CTRL` | `False` | +| 2 | `2026-07-12T22:14:30.177+00:00` | `TxAnswer` | `Q9CTRL` | `True` | +| 3 | `2026-07-12T22:14:30.295+00:00` | `TxAnswer` | `Q9CTRL` | `False` | +| 4 | `2026-07-12T22:14:30.297+00:00` | `Idle` | `None` | `False` | + +## Result + +**PASS** + +## Notes + +Binary under test: `D:\Projects\claude\OpenWSFZ\src\OpenWSFZ.Daemon\bin\Release\net10.0\OpenWSFZ.Daemon.exe` +Binary last-write time (UTC): `2026-07-12T22:14:10.677522+00:00` +QsoAnswererService.cs last-write time (UTC): `2026-07-12T22:14:07.988721+00:00` +Daemon process start time (UTC): `2026-07-12T22:14:15.675283+00:00` +Rebuild check: compiled binary postdates the source edits = `True`; process started after binary was built = `True` + +### Phase A — sanity control (timely arm) + AC-3 live regression (abort mid-TX) + +Arming `Q9CTRL` at `2026-07-12T22:14:30Z` — 0.15 s into window `2026-07-12T22:14:30Z` (timely, ≤ 2.0 s) — expect immediate fire. +POST /api/v1/tx/answer-cq (Q9CTRL) → HTTP 200 +Real `keying: true` observed for `Q9CTRL`: `True` +POST /api/v1/tx/abort (mid-TX, at `2026-07-12T22:14:30Z`) → HTTP 200 +`keying: false` observed after mid-TX abort: `True` +GET /api/v1/tx/status after Phase A abort: `{'state': 'Idle', 'partner': None, 'autoAnswerEnabled': False, 'role': 'answerer', 'callerPartnerSelect': 'First', 'keying': False}` +**Phase A result: PASS** + +### Phase B — D-CALLER-018 repro: hammer Abort while Idle with a deferred target + +Arming `Q9BUG` at `2026-07-12T22:14:52Z` — 7.00 s into window `2026-07-12T22:14:45Z` (late, > 2.0 s) — expect deferral, no fire. +POST /api/v1/tx/answer-cq (Q9BUG) → HTTP 200 +Late-start deferral log line found for `Q9BUG`: `True` (7.0 s into window) +No transmission for `Q9BUG` fired on the first (late) window: `True` +Hammered POST /api/v1/tx/abort x14 while Idle (2026-07-12T22:14:53Z .. 2026-07-12T22:14:54Z) — statuses: [200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200] +Waiting until `2026-07-12T22:15:18Z` (next matching-phase window `2026-07-12T22:15:15Z` + 3 s buffer) to confirm no re-engagement... +GET /api/v1/tx/status after wait: `{'state': 'Idle', 'partner': None, 'autoAnswerEnabled': False, 'role': 'answerer', 'callerPartnerSelect': 'First', 'keying': False}` +'answering at ... phase' log line ever appeared for `Q9BUG` (would prove re-engagement): `False` +No WS txState event names `Q9BUG` as partner after the abort hammer: `True` +**Phase B result: PASS** + diff --git a/qa/d-caller-018-abort-hard-stop-live-verify/live_verify_abort_hardstop.py b/qa/d-caller-018-abort-hard-stop-live-verify/live_verify_abort_hardstop.py new file mode 100644 index 0000000..e959cad --- /dev/null +++ b/qa/d-caller-018-abort-hard-stop-live-verify/live_verify_abort_hardstop.py @@ -0,0 +1,675 @@ +#!/usr/bin/env python3 +"""Live, hardware-in-the-loop verification of D-CALLER-018/016 +(dev-task 2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md) against a real, isolated +OpenWSFZ.Daemon instance. + +WHY THIS EXISTS: dev-task section 5 states plainly that this defect was only caught by reading a +real operating log, not by unit tests — every existing D-CALLER-013 unit test passed the whole +time this was broken, because none of them exercised "Abort while a target is armed but the +service is already Idle." This script reproduces that exact production sequence +(logs/openswfz-20260712T211150Z.log, CX1RL/23:18:30-23:19:00) against a real running daemon over +its real HTTP/WebSocket API, with a real background service loop and real wall-clock FT8 cycle +timing (no FakeTimeProvider) — not a mock. Modelled on +qa/tx-keying-live-verify/live_verify_keying.py's precedent (real isolated daemon, real audio +device via AudioOnlyPttController, no CAT/serial rig or virtual-audio-cable required since this +defect lives entirely in QsoAnswererService's Idle-state bookkeeping, not the decoder). + +WHAT IT PROVES, IN TWO PHASES: + + Phase A (sanity control + AC-3 live regression): arms a pending CQ target timely (< 2.0 s into + its answer window) so it fires immediately — proving the harness can produce a REAL keyed + transmission (ruling out "audio device silently failed to open" as a false PASS) — then + calls the dedicated Abort endpoint mid-transmission and confirms KeyUp/Idle follow promptly. + This is the AC-3 regression: AbortAsync while genuinely mid-TX must keep working exactly as + before the fix. + + Phase B (the actual bug, AC-1 live): arms a second, different pending CQ target late (> 2.0 s + into its answer window) so the late-start guard defers it — reproducing "pending target ... + late start ... deferring to next occurrence" from the log while the service sits Idle with + the target still armed. Then hammers the dedicated Abort endpoint (matching the log's 14 + no-op clicks) while Idle, then waits past the next occurrence of that target's matching-phase + window (~30 s later) and confirms NO transmission fires and the partner never appears in any + txState broadcast — the exact "no recourse" defect from the log, now fixed. + + AC-2 (jump-in / EngageAtAsync) is deliberately NOT exercised live here: AbortAsync's fix clears + _pendingTargetCallsign and _jumpPartner in the same unconditional lock block (same code path, + same guard removed), and the jump-in unit test + (AbortAsync_WhileIdleWithDeferredJumpInTarget_ClearsTarget_NoSubsequentTx) was confirmed to FAIL + against the pre-fix code and PASS against the fix (see PR description) — reproducing it live via + engage-decode's directed-message dispatch would materially lengthen this script for coverage + that unit tests already prove is code-identical to Phase B below. + +REQUIRES: the Release daemon built (`dotnet build OpenWSFZ.slnx -c Release` from the repo root — +this script does not build it for you) and a real audio output device (does not need to be +audible or a virtual cable — CQ transmission via AudioOnlyPttController does not require a decode +input). + +Exit code 0 on PASS, 1 on FAIL, 2 if the environment prerequisites aren't met (e.g. no Release +binary found) — the report still gets written in the exit-2 case, marked +ENVIRONMENT-UNAVAILABLE, so a CI-less environment doesn't produce a false PASS or a +silently-missing report. +""" +import base64 +import json +import os +import platform +import re +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import threading + +# Windows console default (cp1252/OEM) cannot encode the — / ≤ characters used in this script's +# own notes strings; reconfigure stdout/stderr to UTF-8 so `print()` never crashes mid-report +# (the report FILE is always UTF-8 regardless — this only affects console echo). +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +import time +import urllib.error +import urllib.request +from datetime import datetime, timedelta, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +REPORTS_DIR = Path(__file__).resolve().parent / "live-reports" + +PORT = 18768 # distinct scratch port from decode-filter-synth-verify's 18765 / tx-keying's 18767 +BASE = f"http://127.0.0.1:{PORT}" + +OUR_CALLSIGN = "Q1OFZ" +OUR_GRID = "JO33" +PHASE_A_TARGET = "Q9CTRL" # sanity-control target, timely arm, aborted mid-TX (AC-3 live) +PHASE_B_TARGET = "Q9BUG" # the actual D-CALLER-018 repro target, late arm, hammered while Idle + +MAX_LATE_START_SECONDS = 2.0 # must track QsoAnswererService.cs's MaxLateStartSeconds (D-CALLER-016) + +# Tolerant of whatever dash character actually lands in the console sink: the source uses an +# em dash (—), but Windows console best-fit fallback encoding can silently substitute a plain +# ASCII hyphen (confirmed precedent: qa/tx-keying-live-verify/live_verify_keying.py's own +# KEYDOWN_START_RE comment) — match on the surrounding words only, not the punctuation. +LATE_START_LOG_RE = re.compile( + r"pending target '(?P\S+)'.*?late start \((?P[\d.]+) s into window\).*?" + r"deferring to next occurrence") +ANSWERING_LOG_RE = re.compile( + r"pending CQ target '(?P\S+)' at \S+ Hz.*?answering at \S+ phase") + + +# ── HTTP helpers ───────────────────────────────────────────────────────────── + +def http_get(path): + with urllib.request.urlopen(f"{BASE}{path}", timeout=5) as resp: + return json.loads(resp.read().decode("utf-8")) + +def http_post(path, body=None): + data = json.dumps(body if body is not None else {}).encode("utf-8") + req = urllib.request.Request(f"{BASE}{path}", data=data, method="POST", + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + +def wait_for_daemon_ready(timeout_s=15): + deadline = time.time() + timeout_s + while time.time() < deadline: + try: + return http_get("/api/v1/status") + except (urllib.error.URLError, ConnectionError): + time.sleep(0.3) + raise TimeoutError(f"Daemon did not respond on {BASE} within {timeout_s}s.") + + +# ── Audio device resolution (loopback-safe defaults — no cable required) ───── + +INPUT_DEVICE_SUBSTRINGS = ("cable output", "voicemeeter out") +OUTPUT_DEVICE_SUBSTRINGS = ("cable input", "voicemeeter in", "speakers") + +def resolve_devices(): + """Prefers a real virtual-cable/voicemeeter/speakers device over devices[0], which can be a + stale/disconnected virtual device that silently fails to open — see + qa/tx-keying-live-verify/live_verify_keying.py's precedent for why devices[0] is unsafe.""" + devices = http_get("/api/v1/audio/devices") + outputs = http_get("/api/v1/audio/output-devices") + + input_dev = next( + (d for sub in INPUT_DEVICE_SUBSTRINGS for d in devices if sub in d["name"].lower()), + devices[0] if devices else None) + output_dev = next( + (d for sub in OUTPUT_DEVICE_SUBSTRINGS for d in outputs if sub in d["name"].lower()), + outputs[0] if outputs else None) + return input_dev, output_dev + + +# ── Minimal dependency-free WebSocket client (RFC 6455) ────────────────────── +# Loopback connections bypass the SEC-002B passphrase gate server-side, so no auth frame needed. + +class MinimalWsClient: + def __init__(self, host, port, path): + self._sock = socket.create_connection((host, port), timeout=10) + self._sock.settimeout(1.0) + self._buf = b"" + self._closed = False + self._handshake(host, port, path) + + def _handshake(self, host, port, path): + key = base64.b64encode(os.urandom(16)).decode("ascii") + req = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ).encode("ascii") + self._sock.sendall(req) + + self._sock.settimeout(10) + while b"\r\n\r\n" not in self._buf: + chunk = self._sock.recv(4096) + if not chunk: + raise ConnectionError("Socket closed during WS handshake.") + self._buf += chunk + header_end = self._buf.index(b"\r\n\r\n") + 4 + headers = self._buf[:header_end].decode("iso-8859-1") + self._buf = self._buf[header_end:] + if " 101 " not in headers.split("\r\n")[0]: + raise ConnectionError(f"WS handshake failed: {headers.splitlines()[0]!r}") + self._sock.settimeout(1.0) + + def _recv_more(self): + try: + chunk = self._sock.recv(65536) + except socket.timeout: + return False + if not chunk: + self._closed = True + return False + self._buf += chunk + return True + + def read_frame(self): + while True: + if len(self._buf) < 2: + if not self._recv_more(): + return None + continue + b0, b1 = self._buf[0], self._buf[1] + opcode = b0 & 0x0F + masked = (b1 & 0x80) != 0 + plen = b1 & 0x7F + offset = 2 + if plen == 126: + if len(self._buf) < offset + 2: + if not self._recv_more(): + return None + continue + plen = struct.unpack(">H", self._buf[offset:offset + 2])[0] + offset += 2 + elif plen == 127: + if len(self._buf) < offset + 8: + if not self._recv_more(): + return None + continue + plen = struct.unpack(">Q", self._buf[offset:offset + 8])[0] + offset += 8 + mask_key = b"" + if masked: + if len(self._buf) < offset + 4: + if not self._recv_more(): + return None + continue + mask_key = self._buf[offset:offset + 4] + offset += 4 + if len(self._buf) < offset + plen: + if not self._recv_more(): + return None + continue + payload = self._buf[offset:offset + plen] + if masked: + payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) + self._buf = self._buf[offset + plen:] + + if opcode == 0x8: # close + self._closed = True + return None + if opcode == 0x9: # ping -> pong + self._send_frame(0xA, payload) + continue + if opcode == 0x1: # text + return payload.decode("utf-8", errors="replace") + continue + + def _send_frame(self, opcode, payload=b""): + mask_key = os.urandom(4) + masked_payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) + b0 = 0x80 | opcode + length = len(payload) + if length < 126: + header = struct.pack("!BB", b0, 0x80 | length) + elif length < 65536: + header = struct.pack("!BBH", b0, 0x80 | 126, length) + else: + header = struct.pack("!BBQ", b0, 0x80 | 127, length) + self._sock.sendall(header + mask_key + masked_payload) + + def close(self): + try: + self._send_frame(0x8, b"") + except OSError: + pass + try: + self._sock.close() + except OSError: + pass + + +def ws_reader_thread(client, events, stop_flag, errors): + import traceback + try: + while not stop_flag["stop"] and not client._closed: + msg = client.read_frame() + if msg is None: + continue + now = datetime.now(timezone.utc) + try: + parsed = json.loads(msg) + except json.JSONDecodeError: + continue + if parsed.get("type") == "txState": + events.append((now, parsed)) + except Exception: + errors.append(traceback.format_exc()) + + +# ── FT8 15-second cycle helpers (must track QsoAnswererService.cs's IsAPhase/RoundDownTo15s) ─ + +def floor15(dt): + sec = (dt.second // 15) * 15 + return dt.replace(second=sec, microsecond=0) + +def is_a_phase(dt): + return dt.second % 30 == 0 + +def next_boundary(now=None): + now = now or datetime.now(timezone.utc) + ws = floor15(now) + nb = ws + timedelta(seconds=15) + while nb <= now: + nb += timedelta(seconds=15) + return nb + +def sleep_until(target_dt): + now = datetime.now(timezone.utc) + delta = (target_dt - now).total_seconds() + if delta > 0: + time.sleep(delta) + +def iso_z(dt): + return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# ── Daemon process management ───────────────────────────────────────────────── + +def resolve_daemon_binary(): + exe_name = "OpenWSFZ.Daemon.exe" if platform.system() == "Windows" else "OpenWSFZ.Daemon" + candidate = REPO_ROOT / "src" / "OpenWSFZ.Daemon" / "bin" / "Release" / "net10.0" / exe_name + if candidate.exists(): + return candidate + raise FileNotFoundError( + "Release daemon binary not found. Build it first: " + "dotnet build OpenWSFZ.slnx -c Release") + +def start_daemon(binary, config_path, log_path): + log_file = open(log_path, "w", encoding="utf-8") + proc = subprocess.Popen( + [str(binary), "--port", str(PORT), "--config", str(config_path)], + stdout=log_file, stderr=subprocess.STDOUT, cwd=str(REPO_ROOT)) + return proc, log_file + +def stop_daemon(proc, log_file): + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + log_file.close() + + +# ── Report writer ───────────────────────────────────────────────────────────── + +def write_report(status, timestamp_table, environment_note=None, extra_notes=None): + REPORTS_DIR.mkdir(parents=True, exist_ok=True) + ts = datetime.now(timezone.utc) + sha = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=str(REPO_ROOT), + capture_output=True, text=True).stdout.strip() or "unknown" + branch = subprocess.run(["git", "branch", "--show-current"], cwd=str(REPO_ROOT), + capture_output=True, text=True).stdout.strip() or "unknown" + fname = REPORTS_DIR / f"{ts.strftime('%Y-%m-%dT%H%M%SZ')}-{sha}.md" + + lines = [ + "# D-CALLER-018/016 — Abort hard-stop live verification", + "", + f"- **Run at (UTC):** {ts.isoformat()}", + f"- **Git commit:** `{sha}` (branch `{branch}`)", + f"- **Script:** `qa/d-caller-018-abort-hard-stop-live-verify/live_verify_abort_hardstop.py`", + f"- **Dev-task:** `dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md` §5", + "", + ] + + if environment_note: + lines += ["## Environment", "", environment_note, ""] + + if timestamp_table: + lines += ["## WebSocket `txState` events received (UTC wall clock)", "", + "| # | Wall-clock (UTC) | state | partner | keying |", + "|---|---|---|---|---|"] + for i, (t, ev) in enumerate(timestamp_table, start=1): + lines.append( + f"| {i} | `{t.isoformat(timespec='milliseconds')}` | " + f"`{ev.get('state')}` | `{ev.get('partner')}` | `{ev.get('keying')}` |") + lines.append("") + + lines += ["## Result", "", f"**{status}**", ""] + + if extra_notes: + lines += ["## Notes", "", extra_notes, ""] + + fname.write_text("\n".join(lines), encoding="utf-8") + return fname + + +# ── Main ───────────────────────────────────────────────────────────────────── + +def main(): + try: + binary = resolve_daemon_binary() + except FileNotFoundError as e: + fname = write_report("ENVIRONMENT-UNAVAILABLE", [], environment_note=str(e)) + print(f"ENVIRONMENT UNAVAILABLE — report written to {fname}") + return 2 + + binary_mtime = datetime.fromtimestamp(binary.stat().st_mtime, tz=timezone.utc) + source_file = REPO_ROOT / "src" / "OpenWSFZ.Daemon" / "QsoAnswererService.cs" + source_mtime = (datetime.fromtimestamp(source_file.stat().st_mtime, tz=timezone.utc) + if source_file.exists() else None) + + scratch = Path(tempfile.mkdtemp(prefix="owsfz-abort-hardstop-verify-")) + config_path = scratch / "config.json" + logs_dir = scratch / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + daemon_log_path = scratch / "daemon.log" + + proc = None + log_file = None + ws_client = None + notes = [] + ok = False + + try: + # ── Phase 0: throwaway start, just to enumerate real audio devices ── + config_path.write_text("{}", encoding="utf-8") + proc, log_file = start_daemon(binary, config_path, daemon_log_path) + wait_for_daemon_ready() + input_dev, output_dev = resolve_devices() + stop_daemon(proc, log_file) + + # ── Phase 1: write the FINAL config ────────────────────────────────── + config = { + "audioDeviceId": input_dev["id"] if input_dev else None, + "audioOutputDeviceId": output_dev["id"] if output_dev else None, + "port": PORT, + "decodingEnabled": True, + "logLevel": "Debug", # Debug so the "late start ... deferring" line is captured + "decodeLog": { + "enabled": True, + "path": str(logs_dir / "ALL.TXT").replace("\\", "/"), + "dialFrequencyMHz": 7.074, + }, + "tx": { + "autoAnswer": False, # armed at runtime via POST /tx/answer-cq below + "callsign": OUR_CALLSIGN, + "grid": OUR_GRID, + "retryCount": 0, + "watchdogMinutes": 4, + "rxAudioOffsetHz": 1500, + "txAudioOffsetHz": 1500, + "holdTxFreq": False, + "role": "Answerer", + "callerPartnerSelect": "First", + "qsoConfirmation": False, + }, + } + config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") + + # ── Phase 2: the real, correctly-configured daemon start ──────────── + proc_start_time = datetime.now(timezone.utc) + proc, log_file = start_daemon(binary, config_path, daemon_log_path) + wait_for_daemon_ready() + + # ── Phase 3: WebSocket client + reader thread ──────────────────────── + ws_client = MinimalWsClient("127.0.0.1", PORT, "/api/v1/ws") + events = [] + reader_errors = [] + stop_flag = {"stop": False} + reader = threading.Thread( + target=ws_reader_thread, args=(ws_client, events, stop_flag, reader_errors), daemon=True) + reader.start() + + notes.append(f"Binary under test: `{binary}`") + notes.append(f"Binary last-write time (UTC): `{binary_mtime.isoformat()}`") + notes.append(f"QsoAnswererService.cs last-write time (UTC): " + f"`{source_mtime.isoformat() if source_mtime else 'unknown'}`") + notes.append(f"Daemon process start time (UTC): `{proc_start_time.isoformat()}`") + notes.append( + f"Rebuild check: compiled binary postdates the source edits = " + f"`{binary_mtime >= source_mtime if source_mtime else 'unknown'}`; " + f"process started after binary was built = `{proc_start_time >= binary_mtime}`") + notes.append("") + + # ═══════════════════════════════════════════════════════════════════ + # Phase A: sanity control + AC-3 live regression — timely arm fires + # immediately (proves the harness produces a real keyed transmission), + # then Abort mid-TX must stop it promptly exactly as before the fix. + # ═══════════════════════════════════════════════════════════════════ + notes.append("### Phase A — sanity control (timely arm) + AC-3 live regression (abort mid-TX)") + notes.append("") + + nb = next_boundary() + sleep_until(nb + timedelta(milliseconds=150)) + arm_a_time = datetime.now(timezone.utc) + window_a_start = floor15(arm_a_time) + cq_cycle_a = window_a_start - timedelta(seconds=15) + secs_in_a = (arm_a_time - window_a_start).total_seconds() + + notes.append( + f"Arming `{PHASE_A_TARGET}` at `{iso_z(arm_a_time)}` — {secs_in_a:.2f} s into window " + f"`{iso_z(window_a_start)}` (timely, ≤ {MAX_LATE_START_SECONDS} s) — expect immediate fire.") + + status_a, _ = http_post("/api/v1/tx/answer-cq", { + "callsign": PHASE_A_TARGET, + "frequencyHz": 1500.0, + "cqCycleStartUtc": iso_z(cq_cycle_a), + }) + notes.append(f"POST /api/v1/tx/answer-cq ({PHASE_A_TARGET}) → HTTP {status_a}") + + # Wait for a real keying:true event for PHASE_A_TARGET. + deadline = time.time() + 5 + saw_keying_true_a = False + while time.time() < deadline: + for t, e in events: + if t >= arm_a_time and e.get("keying") is True and e.get("partner") == PHASE_A_TARGET: + saw_keying_true_a = True + break + if saw_keying_true_a: + break + time.sleep(0.1) + notes.append(f"Real `keying: true` observed for `{PHASE_A_TARGET}`: `{saw_keying_true_a}`") + + # Abort mid-TX (AC-3 live regression). + abort_mid_tx_time = datetime.now(timezone.utc) + status_abort_a, _ = http_post("/api/v1/tx/abort") + notes.append(f"POST /api/v1/tx/abort (mid-TX, at `{iso_z(abort_mid_tx_time)}`) → HTTP {status_abort_a}") + + deadline = time.time() + 5 + saw_keying_false_after_abort_a = False + while time.time() < deadline: + for t, e in events: + if t >= abort_mid_tx_time and e.get("keying") is False: + saw_keying_false_after_abort_a = True + break + if saw_keying_false_after_abort_a: + break + time.sleep(0.1) + notes.append( + f"`keying: false` observed after mid-TX abort: `{saw_keying_false_after_abort_a}`") + + time.sleep(0.5) + status_a_final = http_get("/api/v1/tx/status") + notes.append(f"GET /api/v1/tx/status after Phase A abort: `{status_a_final}`") + phase_a_ok = ( + saw_keying_true_a + and saw_keying_false_after_abort_a + and status_a_final.get("state") == "Idle" + ) + notes.append(f"**Phase A result: {'PASS' if phase_a_ok else 'FAIL'}**") + notes.append("") + + # ═══════════════════════════════════════════════════════════════════ + # Phase B: the actual D-CALLER-018 defect — late arm defers, then the + # dedicated Abort button is hammered while Idle with the target still + # armed. Before the fix this was a no-op and the target fired anyway + # ~30 s later; after the fix it must never fire. + # ═══════════════════════════════════════════════════════════════════ + notes.append("### Phase B — D-CALLER-018 repro: hammer Abort while Idle with a deferred target") + notes.append("") + + nb2 = next_boundary() + late_offset = 7.0 # comfortably > MaxLateStartSeconds (2.0 s), comfortably < window (15 s) + sleep_until(nb2 + timedelta(seconds=late_offset)) + arm_b_time = datetime.now(timezone.utc) + window_b_start = floor15(arm_b_time) + cq_cycle_b = window_b_start - timedelta(seconds=15) + secs_in_b = (arm_b_time - window_b_start).total_seconds() + + notes.append( + f"Arming `{PHASE_B_TARGET}` at `{iso_z(arm_b_time)}` — {secs_in_b:.2f} s into window " + f"`{iso_z(window_b_start)}` (late, > {MAX_LATE_START_SECONDS} s) — expect deferral, no fire.") + + status_b, _ = http_post("/api/v1/tx/answer-cq", { + "callsign": PHASE_B_TARGET, + "frequencyHz": 1600.0, + "cqCycleStartUtc": iso_z(cq_cycle_b), + }) + notes.append(f"POST /api/v1/tx/answer-cq ({PHASE_B_TARGET}) → HTTP {status_b}") + + # Give the wakeup channel a moment to be processed, then check for the deferral log line. + time.sleep(1.0) + log_text_mid = daemon_log_path.read_text(encoding="utf-8", errors="ignore") + defer_match = None + for m in LATE_START_LOG_RE.finditer(log_text_mid): + if m.group("callsign") == PHASE_B_TARGET: + defer_match = m + notes.append( + f"Late-start deferral log line found for `{PHASE_B_TARGET}`: `{bool(defer_match)}`" + + (f" ({defer_match.group('secs')} s into window)" if defer_match else "")) + + # Confirm no fire happened yet. + no_fire_yet = not any( + t >= arm_b_time and e.get("partner") == PHASE_B_TARGET and e.get("keying") is True + for t, e in events) + notes.append(f"No transmission for `{PHASE_B_TARGET}` fired on the first (late) window: `{no_fire_yet}`") + + # Hammer the dedicated Abort button while Idle with the target still armed — matches the + # log's 14 no-op clicks. + hammer_start = datetime.now(timezone.utc) + hammer_statuses = [] + for _ in range(14): + st, _ = http_post("/api/v1/tx/abort") + hammer_statuses.append(st) + time.sleep(0.1) + hammer_end = datetime.now(timezone.utc) + notes.append( + f"Hammered POST /api/v1/tx/abort x14 while Idle ({iso_z(hammer_start)} .. " + f"{iso_z(hammer_end)}) — statuses: {hammer_statuses}") + + # Wait past the NEXT occurrence of the matching-phase window (~30 s later) plus a buffer. + next_matching_window = window_b_start + timedelta(seconds=30) + wait_until = next_matching_window + timedelta(seconds=3) + notes.append( + f"Waiting until `{iso_z(wait_until)}` (next matching-phase window " + f"`{iso_z(next_matching_window)}` + 3 s buffer) to confirm no re-engagement...") + sleep_until(wait_until) + + log_text_final = daemon_log_path.read_text(encoding="utf-8", errors="ignore") + answering_match_after_hammer = None + for m in ANSWERING_LOG_RE.finditer(log_text_final): + if m.group("callsign") == PHASE_B_TARGET: + answering_match_after_hammer = m + no_fire_after_hammer = not any( + t >= hammer_end and e.get("partner") == PHASE_B_TARGET + for t, e in events) + + status_b_final = http_get("/api/v1/tx/status") + notes.append(f"GET /api/v1/tx/status after wait: `{status_b_final}`") + notes.append( + f"'answering at ... phase' log line ever appeared for `{PHASE_B_TARGET}` " + f"(would prove re-engagement): `{bool(answering_match_after_hammer)}`") + notes.append( + f"No WS txState event names `{PHASE_B_TARGET}` as partner after the abort hammer: " + f"`{no_fire_after_hammer}`") + + phase_b_ok = ( + bool(defer_match) + and no_fire_yet + and all(s == 200 for s in hammer_statuses) + and not answering_match_after_hammer + and no_fire_after_hammer + and status_b_final.get("state") == "Idle" + and status_b_final.get("partner") is None + ) + notes.append(f"**Phase B result: {'PASS' if phase_b_ok else 'FAIL'}**") + notes.append("") + + stop_flag["stop"] = True + if reader_errors: + notes.append("**WS reader thread raised an exception:**") + notes.append("```") + notes.extend(reader_errors) + notes.append("```") + + ok = phase_a_ok and phase_b_ok + + if not ok: + log_lines = log_text_final.splitlines() + tail = log_lines[-120:] if len(log_lines) > 120 else log_lines + notes.append("") + notes.append("**Daemon log tail (last 120 lines) — diagnostic aid on failure:**") + notes.append("```") + notes.extend(tail) + notes.append("```") + + status = "PASS" if ok else "FAIL" + timestamp_table = list(events) + fname = write_report(status, timestamp_table, extra_notes="\n".join(notes)) + print(f"{status} — report written to {fname}") + for line in notes: + print(line) + return 0 if ok else 1 + + finally: + if ws_client is not None: + stop_flag = locals().get("stop_flag") + if stop_flag is not None: + stop_flag["stop"] = True + ws_client.close() + if proc is not None and log_file is not None: + stop_daemon(proc, log_file) + shutil.rmtree(scratch, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/OpenWSFZ.Daemon/QsoAnswererService.cs b/src/OpenWSFZ.Daemon/QsoAnswererService.cs index 813650e..c4774da 100644 --- a/src/OpenWSFZ.Daemon/QsoAnswererService.cs +++ b/src/OpenWSFZ.Daemon/QsoAnswererService.cs @@ -133,10 +133,18 @@ public sealed class QsoAnswererService : BackgroundService, IQsoController /// /// Maximum number of seconds into a 15-second FT8 window that TX may still be started. - /// An FT8 signal is 12.64 s; starting later than this causes overrun into the next window. - /// A 1.5 s threshold leaves ~0.86 s margin (15 - 12.64 - 1.5 = 0.86 s). + /// An FT8 signal is 12.64 s; the true physical ceiling is 15 - 12.64 = 2.36 s — starting any + /// later overruns into the next window and can prevent the far station from decoding cleanly. + /// D-CALLER-013 originally set this to 1.5 s, leaving only ~0.8-1.0 s of realistic click budget + /// after decode processing (0.4-0.7 s observed) — in practice this meant almost every manual + /// click (openswfz-20260712T211150Z.log: 1.9 s and 3.7 s observed) missed its window and was + /// deferred a full 30 s (phase alternates every 15 s, so the next matching-phase window is + /// always two cycles away, never one — see D-CALLER-016). 2.0 s leaves a 0.36 s hard safety + /// margin against overrun, comfortably covering the ~50-90 ms KeyDown/device-open jitter observed + /// in production logs, while catching realistic decode-to-click latency instead of nearly always + /// missing it. /// - private const double MaxLateStartSeconds = 1.5; + private const double MaxLateStartSeconds = 2.0; /// /// Written by immediately after setting the pending target, @@ -256,6 +264,24 @@ public Task EngageAtAsync( /// public async Task AbortAsync(CancellationToken ct = default) { + // D-CALLER-018: unconditionally clear any armed-but-not-yet-fired pending target or + // jump-in, regardless of current _state. A target armed by AnswerCqAsync / EngageAtAsync / + // TryEngageExternal fires independently of _state (it is specifically checked while Idle — + // see HandleIdleAsync). Previously this method returned immediately whenever _state was + // already Idle, which meant an armed target could NOT be cancelled by the operator once the + // service returned to Idle — it fired regardless, up to ~30 s later, no matter how many times + // Abort was clicked. Abort must be an unconditional hard stop: nothing may re-engage after it + // until the operator explicitly requests it again. See + // dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md. + lock (_stateLock) + { + _pendingTargetCallsign = null; + _pendingTargetFrequencyHz = 0.0; + _pendingTargetIsAPhase = false; + _pendingTargetSetAt = default; + _jumpPartner = null; + } + if (_state == QsoState.Idle) return; _logger.LogInformation( diff --git a/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs b/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs index 2ce3eb2..337e9a4 100644 --- a/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs +++ b/tests/OpenWSFZ.Daemon.Tests/QsoAnswererServiceTests.cs @@ -58,8 +58,8 @@ public async Task InitializeAsync() }); var adifLog = new AdifLogWriter(store, NullLogger.Instance); - // D-CALLER-013: inject a FakeTimeProvider at the start of an A-phase window - // (second = 0 → 0 s into the window ≤ MaxLateStartSeconds = 1.5 s) so the + // D-CALLER-013/016: inject a FakeTimeProvider at the start of an A-phase window + // (second = 0 → 0 s into the window ≤ MaxLateStartSeconds = 2.0 s) so the // late-start guard always passes for the shared SUT. // Tests that determine phase from the real clock (D-TX-UI-007 wakeup tests) // use DateTimeOffset.UtcNow directly inside AnswerCqAsync — they are not @@ -2348,7 +2348,7 @@ public async Task QsoComplete_CatConnectedOn20m_AdifFileHasCorrectBand() } } - // ── D-CALLER-013: late-start guard (MaxLateStartSeconds = 1.5 s) ───────── + // ── D-CALLER-013/016: late-start guard (MaxLateStartSeconds = 2.0 s) ───── /// /// Controllable used by D-CALLER-013 tests so the late-start @@ -2403,7 +2403,7 @@ private static (QsoAnswererService sut, IPttController ptt, public async Task PendingTarget_LateStart_IsDeferred_ThenFiresNextCycle() { // FakeTime = 5 s into the :00 A-phase window (17:30:05). - // RoundDownTo15s(17:30:05) = 17:30:00 → secondsIntoWindow = 5.0 > 1.5 → late. + // RoundDownTo15s(17:30:05) = 17:30:00 → secondsIntoWindow = 5.0 > 2.0 (D-CALLER-016) → late. var fakeTime = new FakeTimeProvider( new DateTimeOffset(2026, 6, 27, 17, 30, 5, TimeSpan.Zero)); var (sut, ptt, channel, stopCts) = BuildLateStartSut(fakeTime); @@ -2427,7 +2427,7 @@ await sut.AnswerCqAsync(PartnerCall, AudioFreqHz, await ptt.DidNotReceive().KeyDownAsync(Arg.Any()); // Advance FakeTime to 0.5 s into the next A-phase window (17:30:30.5). - // 0.5 s ≤ 1.5 s → guard passes → TX must fire. + // 0.5 s ≤ 2.0 s → guard passes → TX must fire. fakeTime.UtcNow = new DateTimeOffset(2026, 6, 27, 17, 30, 30, 500, TimeSpan.Zero); // Batch 2: CycleStart :15 (B-phase) → +15 s = :30 A-phase ✓ (same phase, next occurrence). @@ -2447,7 +2447,7 @@ await sut.AnswerCqAsync(PartnerCall, AudioFreqHz, public async Task PendingTarget_TimelyStart_FiresImmediately() { // FakeTime = 0.5 s into the :00 A-phase window. - // 0.5 s ≤ 1.5 s → guard does not defer → TX fires on the first correct-phase batch. + // 0.5 s ≤ 2.0 s → guard does not defer → TX fires on the first correct-phase batch. var fakeTime = new FakeTimeProvider( new DateTimeOffset(2026, 6, 27, 17, 30, 0, 500, TimeSpan.Zero)); var (sut, ptt, channel, stopCts) = BuildLateStartSut(fakeTime); @@ -2476,7 +2476,7 @@ await sut.AnswerCqAsync(PartnerCall, AudioFreqHz, [Fact(DisplayName = "D-CALLER-013 C: Jump-in — late double-click (5 s in) is deferred; fires at next occurrence")] public async Task JumpIn_LateStart_IsDeferred_ThenFiresNextCycle() { - // FakeTime = 5 s into the :00 A-phase window → late for A-phase TX. + // FakeTime = 5 s into the :00 A-phase window → late for A-phase TX (> 2.0 s, D-CALLER-016). var fakeTime = new FakeTimeProvider( new DateTimeOffset(2026, 6, 27, 17, 30, 5, TimeSpan.Zero)); var (sut, ptt, channel, stopCts) = BuildLateStartSut(fakeTime); @@ -2522,7 +2522,7 @@ await sut.EngageAtAsync( public async Task JumpIn_TimelyStart_FiresImmediately() { // FakeTime = 0.2 s into the :00 A-phase window. - // 0.2 s ≤ 1.5 s → guard does not defer → TX fires on the first correct-phase batch. + // 0.2 s ≤ 2.0 s → guard does not defer → TX fires on the first correct-phase batch. var fakeTime = new FakeTimeProvider( new DateTimeOffset(2026, 6, 27, 17, 30, 0, 200, TimeSpan.Zero)); var (sut, ptt, channel, stopCts) = BuildLateStartSut(fakeTime); @@ -2551,6 +2551,103 @@ await sut.EngageAtAsync( await ptt.DisposeAsync(); } + // ── D-CALLER-018: AbortAsync must be a hard stop, even while already Idle ──── + + [Fact(DisplayName = "D-CALLER-018 AC-1: AbortAsync while Idle with a deferred pending CQ target clears it — no subsequent TX")] + public async Task AbortAsync_WhileIdleWithDeferredPendingTarget_ClearsTarget_NoSubsequentTx() + { + // Reproduces the exact production sequence from + // dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md §1.1: a target is + // armed, its first opportunity is a late start (deferred to the next matching-phase + // occurrence, per D-CALLER-013/016), and — critically — while it sits Idle waiting for that + // next occurrence, the operator clicks Abort. Before the D-CALLER-018 fix, AbortAsync + // no-opped whenever _state was already Idle, so the deferred target fired anyway ~30 s later + // regardless of the abort (the log showed 14 Abort clicks, all no-ops, followed by TX firing). + // This test proves it no longer does. + var fakeTime = new FakeTimeProvider( + new DateTimeOffset(2026, 6, 27, 17, 30, 5, TimeSpan.Zero)); // 5 s into A-phase window → late + var (sut, ptt, channel, stopCts) = BuildLateStartSut(fakeTime); + await sut.StartAsync(stopCts.Token); + + await sut.AnswerCqAsync(PartnerCall, AudioFreqHz, + new DateTimeOffset(2026, 6, 27, 17, 29, 15, TimeSpan.Zero), + CancellationToken.None); + sut._wakeupChannel.Reader.TryRead(out _); // drain wakeup + + // First matching-phase batch: late-start guard defers (target remains armed for the next + // occurrence — this is the "quietly waiting" state the bug exploited). + channel.Writer.TryWrite(new DecodeBatch( + new DateTimeOffset(2026, 6, 27, 17, 29, 45, TimeSpan.Zero), + Array.Empty())); + await Task.Delay(300); + sut.State.Should().Be(QsoState.Idle, "late-start guard defers TX but leaves the service Idle"); + await ptt.DidNotReceive().KeyDownAsync(Arg.Any()); + + // Operator clicks Abort while sitting Idle with the deferred target still armed. + await sut.AbortAsync(); + + // Advance to a timely point in the next matching-phase window — absent the fix, this is + // exactly where the deferred target would fire (see "D-CALLER-013 A" above). + fakeTime.UtcNow = new DateTimeOffset(2026, 6, 27, 17, 30, 30, 500, TimeSpan.Zero); + channel.Writer.TryWrite(new DecodeBatch( + new DateTimeOffset(2026, 6, 27, 17, 30, 15, TimeSpan.Zero), + Array.Empty())); + await Task.Delay(300); + + sut.State.Should().Be(QsoState.Idle, + "Abort must be a hard stop — no TX may fire after an Idle-state abort"); + await ptt.DidNotReceive().KeyDownAsync(Arg.Any()); + + await stopCts.CancelAsync(); + await sut.StopAsync(CancellationToken.None); + await ptt.DisposeAsync(); + } + + [Fact(DisplayName = "D-CALLER-018 AC-2: AbortAsync while Idle with a deferred jump-in target clears it — no subsequent TX")] + public async Task AbortAsync_WhileIdleWithDeferredJumpInTarget_ClearsTarget_NoSubsequentTx() + { + // Same scenario as AC-1 but for a jump-in target armed via EngageAtAsync (double-click on a + // directed report/RR73/73 row) rather than a pending CQ target armed via AnswerCqAsync. + var fakeTime = new FakeTimeProvider( + new DateTimeOffset(2026, 6, 27, 17, 30, 5, TimeSpan.Zero)); // 5 s into A-phase window → late + var (sut, ptt, channel, stopCts) = BuildLateStartSut(fakeTime); + await sut.StartAsync(stopCts.Token); + + await sut.EngageAtAsync( + PartnerCall, AudioFreqHz, + new DateTimeOffset(2026, 6, 27, 17, 29, 15, TimeSpan.Zero), // their B-phase decode + EngagePoint.SendReport, + CancellationToken.None); + sut._wakeupChannel.Reader.TryRead(out _); // drain wakeup + + // First matching-phase batch: late-start guard defers. + channel.Writer.TryWrite(new DecodeBatch( + new DateTimeOffset(2026, 6, 27, 17, 29, 45, TimeSpan.Zero), + Array.Empty())); + await Task.Delay(300); + sut.State.Should().Be(QsoState.Idle, "late-start guard defers jump-in TX but leaves the service Idle"); + await ptt.DidNotReceive().KeyDownAsync(Arg.Any()); + + // Operator clicks Abort while sitting Idle with the deferred jump-in target still armed. + await sut.AbortAsync(); + + // Advance to a timely point in the next matching-phase window — absent the fix, this is + // exactly where the deferred jump-in would fire (see "D-CALLER-013 C" above). + fakeTime.UtcNow = new DateTimeOffset(2026, 6, 27, 17, 30, 30, 300, TimeSpan.Zero); + channel.Writer.TryWrite(new DecodeBatch( + new DateTimeOffset(2026, 6, 27, 17, 30, 15, TimeSpan.Zero), + Array.Empty())); + await Task.Delay(300); + + sut.State.Should().Be(QsoState.Idle, + "Abort must be a hard stop — no jump-in TX may fire after an Idle-state abort"); + await ptt.DidNotReceive().KeyDownAsync(Arg.Any()); + + await stopCts.CancelAsync(); + await sut.StopAsync(CancellationToken.None); + await ptt.DisposeAsync(); + } + // ── decode-panel-filtering: automation gating (tasks 3.2–3.4) ──────────── /// Simple mutable test double — no broadcast, just state. diff --git a/web/js/main.js b/web/js/main.js index 58d2926..51c50f1 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -9,7 +9,7 @@ import { connect } from './ws.js'; import { getConfig, getFrequencies, postTune, postAudioOffset, getTxStatus, postTxEnable, postTxDisable, postTxAbort, - postTxAnswerCq, postTxSelectResponder, postTxCallCq, postTxStopCq, + postTxSelectResponder, postTxCallCq, postTxStopCq, postTxCallerPartnerSelect, getApiKey, getPropModes, postLogQso, postTxEngageDecode, @@ -724,55 +724,15 @@ function handleDecodes(results) { // Store cycle-start UTC string as a data attribute for the dblclick handler. tr.dataset.cqCycleStartUtc = parseFt8CycleStartUtc(r.time); - // CQ row highlighting and double-click-to-answer (TX-D01, cq-row-dblclick-to-answer). + // CQ row highlighting (TX-D01). The double-click-to-answer gesture itself is handled + // exclusively by the D-CALLER-012 engage-decode listener below (D-CALLER-019 removed the + // redundant legacy postTxAnswerCq dblclick handler that used to live here — it always lost + // the race against engage-decode's own abort-then-dispatch and produced a guaranteed + // spurious 409 on every double-click; see + // dev-tasks/2026-07-12-answerer-abort-hard-stop-and-reengage-timing.md). if (r.message.startsWith('CQ ')) { tr.classList.add('decode-cq'); tr.style.cursor = 'pointer'; - - let inFlight = false; - tr.addEventListener('dblclick', async (event) => { - event.preventDefault(); // best-effort: browsers don't reliably - // treat text selection as dblclick's - // preventable default action - // Explicitly clear whatever the native double-click "select word" behavior just - // selected. preventDefault() alone was confirmed insufficient (cq-dblclick-after.mjs, - // task 4.3 first pass — a stray "CQ " selection remained). An earlier attempt fixed - // this with `user-select: none` on .decode-cq in app.css, but that blocked ALL text - // selection on every CQ row permanently — including a deliberate click-and-drag to - // copy a callsign, which the Captain reported as a regression. Clearing the selection - // here instead only removes what THIS double-click produced, leaving ordinary - // click-and-drag selection on CQ rows fully working the rest of the time. - window.getSelection()?.removeAllRanges(); - if (inFlight) return; // guard already-queued duplicate events - inFlight = true; - tr.style.pointerEvents = 'none'; // belt-and-suspenders for mouse - const callsign = extractCqCallsign(r.message); - if (!callsign) { - inFlight = false; - tr.style.pointerEvents = ''; - return; - } - const cqCycleStartUtc = tr.dataset.cqCycleStartUtc; - try { - const status = await postTxAnswerCq(callsign, r.freqHz, cqCycleStartUtc); - renderTxPanel(status.state, status.partner, status.autoAnswerEnabled, undefined, status.keying); - // Delay guard reset to block a rapid repeat double-click (~130–185 ms interval). - // On success the operator does not need to retry; 400 ms is harmless (D-TX-UI-005). - setTimeout(() => { - inFlight = false; - tr.style.pointerEvents = ''; - }, 400); - } catch (err) { - // On error, reset immediately so the operator can retry. - inFlight = false; - tr.style.pointerEvents = ''; - if (/** @type {any} */ (err)?.status === 409) { - console.warn('TX not Idle — CQ double-click ignored.'); - } else { - console.error('postTxAnswerCq error:', err); - } - } - }); } // Highlight any row addressed to the operator's callsign in red. @@ -844,7 +804,15 @@ function handleDecodes(results) { // The dblclick then calls engage-decode which performs the abort + re-engage // atomically on the server. let engageInFlight = false; - tr.addEventListener('dblclick', async () => { + tr.addEventListener('dblclick', async (event) => { + event.preventDefault(); // best-effort: browsers don't reliably + // treat text selection as dblclick's + // preventable default action + // D-CALLER-019: this listener is now the only dblclick handler on CQ rows (the redundant + // legacy postTxAnswerCq handler was removed above). Carry forward its stray-selection + // cleanup so double-clicking still doesn't leave a "CQ " text selection behind — see the + // history this cleanup used to live under, above the removed block. + window.getSelection()?.removeAllRanges(); if (engageInFlight) return; engageInFlight = true;