From dfe3afbf2d3da3e2ae2e9db976b6a8c4ec2a0cc9 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Tue, 4 Aug 2026 19:25:14 +0200 Subject: [PATCH] fix(qsl): cancel an in-flight confirmation download on close / new download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LoTW/QRZ confirmation download ran on the app-lifetime context, so it kept going after the QSL Manager was closed and a new download didn't stop the previous one. A slow QRZ sync therefore bled its "flag cleared" log into a freshly started LoTW download — the operator saw QRZ activity during a LoTW run. Now each download gets a cancellable context: starting one cancels the previous, closing the window cancels it (CancelConfirmations), and the parse loops bail promptly on cancel. (A LoTW http 503 is separate — ARRL's server.) --- app.go | 44 +++++++++++++++++++-- changelog.json | 6 ++- frontend/src/components/QSLManagerModal.tsx | 6 ++- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 ++ 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/app.go b/app.go index ef300a1..1af5d08 100644 --- a/app.go +++ b/app.go @@ -622,6 +622,12 @@ type App struct { dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends pttMu sync.Mutex + // confDLMu/confDLCancel cancel the in-flight confirmation download (LoTW/QRZ) + // so closing the QSL Manager — or starting another download — stops the previous + // one instead of leaving it running against the app-lifetime context (which made + // a still-running QRZ sync bleed its log into a freshly started LoTW download). + confDLMu sync.Mutex + confDLCancel context.CancelFunc udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets) relayAutoMu sync.Mutex // serialises relay auto-control evaluation @@ -10180,13 +10186,35 @@ func (a *App) DownloadConfirmations(service string, addNotFound bool, since stri } svc := extsvc.Service(service) cfg := a.loadExternalServices() - go a.runDownloadConfirmations(svc, cfg, addNotFound, since) + // Cancel any download still running (a slow QRZ sync, say) before starting this + // one, so the two don't run at once and interleave their logs — and derive a + // cancellable context so closing the QSL Manager can stop it too. + a.confDLMu.Lock() + if a.confDLCancel != nil { + a.confDLCancel() + } + ctx, cancel := context.WithCancel(a.ctx) + a.confDLCancel = cancel + a.confDLMu.Unlock() + go a.runDownloadConfirmations(ctx, svc, cfg, addNotFound, since) return nil } -func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool, since string) { +// CancelConfirmations stops the in-flight LoTW/QRZ download — the QSL Manager +// calls it when the window closes, so work doesn't keep running in the background. +func (a *App) CancelConfirmations() { + a.confDLMu.Lock() + if a.confDLCancel != nil { + a.confDLCancel() + a.confDLCancel = nil + } + a.confDLMu.Unlock() +} + +func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool, since string) { emit := func(line string) { - if a.ctx != nil { + // Don't keep logging into a closed/cancelled window. + if a.ctx != nil && ctx.Err() == nil { wruntime.EventsEmit(a.ctx, "qslmgr:log", line) } } @@ -10195,7 +10223,6 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total}) } } - ctx := a.ctx matched, total, added := 0, 0, 0 // resolveSince turns the UI's request into a concrete date (or ""): @@ -10247,6 +10274,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe var items []ConfirmationItem var unmatched []string perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { + if ctx.Err() != nil { + return ctx.Err() // window closed / superseded + } q, ok := adif.RecordToQSO(rec) if !ok { return nil @@ -10429,6 +10459,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe // QRZ FETCH returns headerless ADIF (no ); prepend one so the // parser treats the stream as records. perr := adif.Parse(strings.NewReader("\n"+adifText), func(rec adif.Record) error { + if ctx.Err() != nil { + return ctx.Err() // window closed / superseded — stop clearing flags + } parsed++ for k := range rec { allKeys[k] = true @@ -10598,6 +10631,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe var items []ConfirmationItem var unmatched []string perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { + if ctx.Err() != nil { + return ctx.Err() // window closed / superseded + } q, ok := adif.RecordToQSO(rec) if !ok { return nil diff --git a/changelog.json b/changelog.json index 95ecccb..bba957b 100644 --- a/changelog.json +++ b/changelog.json @@ -5,12 +5,14 @@ "en": [ "CW keyer (Kenwood/Elecraft): choosing the Kenwood/Elecraft engine now actually switches to it. A bug left the keyer on WinKeyer even though the setting showed Kenwood/Elecraft, so its CW-over-CAT never ran.", "Kenwood/Elecraft data mode: a new setting (Settings → CAT) chooses what a data mode (FT8/PSK…) sets on the rig — USB (default), DATA mode (MD6, for an Elecraft K3/K4), or leave the rig's mode unchanged (safest for a TS-590SG/TS-990S, whose data mode is a USB modifier set on the radio). There is no single command that fits every rig, so it's now the operator's choice.", - "Ultrabeam over a remote link: changing the pattern (Normal / 180° / bidirectional) no longer snaps back to the old one after a few seconds. The commanded pattern is now held while the motors are still moving plus a grace window after they stop, so the slow remote status poll — which lagged behind the antenna — can't revert the display while the change is in flight." + "Ultrabeam over a remote link: changing the pattern (Normal / 180° / bidirectional) no longer snaps back to the old one after a few seconds. The commanded pattern is now held while the motors are still moving plus a grace window after they stop, so the slow remote status poll — which lagged behind the antenna — can't revert the display while the change is in flight.", + "QSL Manager: closing the window — or starting another download — now cancels the one still in progress. A slow QRZ confirmation sync kept running against the app for its whole life, so its log bled into a freshly started LoTW download and you saw QRZ activity during a LoTW run. (A LoTW \"http 503\" is separate — that's the ARRL server being down; retry later.)" ], "fr": [ "Keyer CW (Kenwood/Elecraft) : choisir le moteur Kenwood/Elecraft y bascule désormais réellement. Un bug laissait le keyer sur WinKeyer alors que le réglage affichait Kenwood/Elecraft, son CW-sur-CAT ne démarrait donc jamais.", "Mode data Kenwood/Elecraft : un nouveau réglage (Réglages → CAT) choisit ce qu'un mode data (FT8/PSK…) règle sur la radio — USB (défaut), mode DATA (MD6, pour un Elecraft K3/K4), ou ne pas changer le mode de la radio (le plus sûr pour un TS-590SG/TS-990S dont le mode data est un modificateur d'USB réglé sur la radio). Aucune commande unique ne convient à toutes les radios, c'est donc désormais au choix de l'opérateur.", - "Ultrabeam en remote : changer le diagramme (Normal / 180° / bidirectionnel) ne revient plus à l'ancien au bout de quelques secondes. Le diagramme commandé est maintenu tant que les moteurs bougent, plus une fenêtre de grâce après leur arrêt — le poll de statut distant, lent et en retard sur l'antenne, ne peut donc plus faire revenir l'affichage pendant que le changement est en cours." + "Ultrabeam en remote : changer le diagramme (Normal / 180° / bidirectionnel) ne revient plus à l'ancien au bout de quelques secondes. Le diagramme commandé est maintenu tant que les moteurs bougent, plus une fenêtre de grâce après leur arrêt — le poll de statut distant, lent et en retard sur l'antenne, ne peut donc plus faire revenir l'affichage pendant que le changement est en cours.", + "QSL Manager : fermer la fenêtre — ou lancer un autre téléchargement — annule désormais celui en cours. Une synchro de confirmations QRZ lente continuait de tourner tant que l'app vivait, son journal se mélangeait donc à un téléchargement LoTW fraîchement lancé et on voyait de l'activité QRZ pendant un run LoTW. (Un « http 503 » sur LoTW est un autre sujet : c'est le serveur de l'ARRL indisponible ; réessayez plus tard.)" ] }, { diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index b470cec..27583a9 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -8,7 +8,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; -import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App'; +import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App'; import { Input } from '@/components/ui/input'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { EventsOn } from '../../wailsjs/runtime/runtime'; @@ -120,6 +120,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi if (service === 'pota' || service === 'paper') { setUploadCall(''); return; } UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall('')); }, [service]); + // Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download + // still running so a slow QRZ/LoTW sync doesn't keep going in the background and + // bleed its log into the next one. + useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []); const [potaSyncing, setPotaSyncing] = useState(false); const [potaRes, setPotaRes] = useState(null); const [potaErr, setPotaErr] = useState(''); diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 062132c..f9825dd 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -97,6 +97,8 @@ export function CIVTraceEnabled():Promise; export function CWDecoderRunning():Promise; +export function CancelConfirmations():Promise; + export function ChatAvailable():Promise; export function CheckForUpdate():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 4b127fb..3b2d674 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -142,6 +142,10 @@ export function CWDecoderRunning() { return window['go']['main']['App']['CWDecoderRunning'](); } +export function CancelConfirmations() { + return window['go']['main']['App']['CancelConfirmations'](); +} + export function ChatAvailable() { return window['go']['main']['App']['ChatAvailable'](); }