Skip to content

port(upstream#1897): stop watchdog force-reconnect racing paho's retry loop - #29

Merged
dborup merged 4 commits into
masterfrom
codex/port-upstream-1897-watchdog-reconnect-race
Sep 26, 2026
Merged

dborup merged 4 commits into
masterfrom
codex/port-upstream-1897-watchdog-reconnect-race

Conversation

@adminopenclaw8-sketch

@adminopenclaw8-sketch adminopenclaw8-sketch commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Split out of #25 (commit 43863a09 there). This branch holds exactly one upstream change plus behavioural tests for it, so it can be reviewed, tested and reverted on its own.

Upstream

  • Upstream PR Kpa-clawbot/CoreScope#1897 by Jonher937, merged 2026-09-02.
  • Upstream commit: 647841c99033c3574897f288b144f5e82721b4c4.
  • Applied with git cherry-pick -x onto master fda24ca5. Upstream authorship is kept, and the commit message carries the (cherry picked from commit …) line. The patch-id is identical to the upstream commit.

Problem

The MQTT stall watchdog's forced reconnect did client.Disconnect(250); client.Connect(). paho's IsConnected() is also true while paho's own auto-reconnect loop is retrying, so the watchdog fires during normal retries too. Disconnect then tears down paho's retry loop. The immediate Connect() runs while the client is still disconnecting and fails, and that error was discarded. The source then has nothing retrying until the next watchdog trigger, which can repeat and compound into long outages.

Change

  • buildForceReconnectFn only calls Disconnect(250) when client.IsConnectionOpen() is true. That is status strictly connected, i.e. the genuine half-open-socket case.
  • It then calls Connect() and logs a token error instead of discarding it.
  • IsConnectionOpen exists in the pinned paho.mqtt.golang v1.5.0. The changed production lines are identical to upstream.

Tests

  • cmd/ingestor/mqtt_force_reconnect_race_test.go (from upstream) checks the call sequence against a fake client.
  • An earlier independent review found that structural test insufficient: 8 of 18 compile-valid mutants survived. Among them was a mutant that moves Disconnect into a goroutine in the retry branch. It reproduces the fix: stop watchdog force-reconnect from racing paho's own retry loop Kpa-clawbot/CoreScope#1897 outage but stayed green, even under -race.
  • cmd/ingestor/mqtt_force_reconnect_paho_test.go (commit cb7dc518) closes that gap. It drives a real paho client built with buildMQTTOpts against a small in-test broker on 127.0.0.1:0, and triggers through the watchdog's own path (maybeForceReconnect → buildForceReconnectFn). Its tests:
  • A targeted CI step, Race-check Go ingestor force-reconnect, runs go test -race -run 'ForceReconnect' for the ingestor. The full ingestor suite still runs without -race.

Verification

Check Result
Mutants (19, compile-valid) 5 survived before, 1 after. The survivor is equivalent: Disconnect(5000) only raises a worst-case bound. The goroutine mutants are killed without -race, 5/5 deterministic.
Stability of the new tests -count=20 60/60; -count=10 -race 30/30, 0 data races; 15/15 while the full suite ran in parallel
Full ingestor suite, vet, gofmt pass
CI run 36227180160 on cb7dc518 success: Go Build & Test (race step ok … 5.674s), Playwright E2E, local Docker build. GHCR login/push, release, staging deploy and badges skipped.

An earlier A/B against a real broker showed the difference: the old code issued 0 CONNECTs after a forced reconnect and never recovered, while the new code tracks paho's own retry rate (0.99 vs 1.00 CONNECT/s) and recovered in about 19 s. The details are in the PR comments.

Scope against master

cmd/ingestor/main.go, the two test files above, and one added test step in .github/workflows/deploy.yml. Fork guards are untouched.

Known follow-ups (not in this PR)

  • Log noise from Connect() while the status is connecting.
  • A shutdown panic in mqtt_watchdog.go, recommended by the review as a separate issue.

Review note

The behavioural tests were written in a separate session. The production change is an unchanged upstream cherry-pick. A post-merge review of the test commit is planned.

🤖 Generated with Claude Code

Jonher937 and others added 2 commits September 13, 2026 10:42
…pa-clawbot#1897)

Relates to Kpa-clawbot#1335, which was already closed by PR Kpa-clawbot#1336 shipping the
naive `client.Disconnect(250); client.Connect()` force-reconnect. That
fix has its own bug: liveness.IsConnectedFn (paho's IsConnected())
reports true for the entire time paho is actively retrying, not just
when genuinely connected, so the watchdog's stall check cannot tell a
half-open TCP socket (the original Kpa-clawbot#1335 case) from a broker that paho
is already correctly reconnecting to. Unconditionally calling
Disconnect(250) then Connect() on that second, transitional case races
paho's status machine and permanently kills its retry loop, requiring
another watchdog trigger to recover, sometimes compounding into 100+
minute outages.

This is a different failure mode from Kpa-clawbot#1749/PR Kpa-clawbot#1853: that bug is a
blocking log.Print() write freezing the entire watchdog loop before
ForceReconnectFn is ever called. This bug only manifests once
ForceReconnectFn does fire, so the two fixes are independent and touch
disjoint files.

buildForceReconnectFn now gates Disconnect() on IsConnectionOpen() (true
only when status is strictly connected) so it only tears down a
genuinely open connection, and logs Connect()'s error token instead of
discarding it.

(cherry picked from commit 647841c)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dborup

dborup commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Måling mod en rigtig broker — PR'ens kernepåstand er bekræftet, og effekten er stor

Branchen er synkroniseret med master 96319acc (almindelig merge-commit 9376930f, ingen rebase/squash/force-push). go build, go vet og de målrettede tests er grønne.

Verifikationen er kørt mod en in-process MQTT-broker med den rigtige paho-klient, så den gamle og den nye ForceReconnectFn kunne sammenlignes direkte i de to scenarier, der betyder noget. Ingen Docker, ingen ekstern broker, ingen produktionskontakt.

Det afgørende A/B: broker nede, paho retry'er allerede, watchdoggen udløser

gammel kode ny kode
5 s efter force-reconnect (broker stadig nede) DEAD — intet retry'er, 1 CONNECT RETRYING, 4 CONNECT
broker kommer op igen recovered = false efter 1m0,006s recovered = true efter 11 ms
slut-tilstand DEAD CONNECTED

Med realistiske produktions-timings er det endnu skarpere: 40 s efter force-reconnect er den gamle kode DEAD med 0 CONNECT-pakker udstedt. Det er præcis den fejlmekanisme, PR-teksten beskriver — og den er nu observeret, ikke udledt: det gamle Disconnect(250) river paho's egen retry-løkke ned for altid, og der er så intet tilbage, der forsøger, før watchdoggen udløser igen.

Den sag, rettelsen stadig skal kunne klare, er uændret

Case A: ægte halv-åben TCP (den oprindelige Kpa-clawbot#1335-situation) gammel ny
genoprettet ja, 6 ms ja, 6 ms
CONNECT-pakker 1 1
slut-tilstand CONNECTED CONNECTED

Identisk. Rettelsen ofrer ikke den case, den blev lavet for.

Diskriminatoren holder

[connected]    IsConnected=true  IsConnectionOpen=true
[reconnecting] IsConnected=true  IsConnectionOpen=false   <-- watchdoggen ser "forbundet men tavs" => klassificerer Stalled
over et 2s vedvarende retry-vindue: IsConnectionOpen()==true samples = 0/400

Påstand (a) og (b) er dermed bekræftet med 0 falske positiver ud af 400 målinger. Påstand (c) ligeledes: 5 s efter Disconnect(250) under retry er tilstanden DEAD (nothing retrying) med 0 CONNECT-pakker i vinduet. Og (e): Disconnect(250) fra reconnecting returnerede efter 251 ms, mens tilstanden umiddelbart efter stadig var RETRYING — altså returnerer den mens status endnu er transitional, præcis som kommentaren siger.

Én påstand i kommentaren er for bred — målt

Kommentaren siger, at Connect() alene er "a safe no-op per paho when a retry is already under way". Det holder i tilstanden reconnecting:

(d) Connect() while status=reconnecting: returned in 3µs, Error()(no Wait)=<nil>, token already resolved=true
    1.5s senere: state=RETRYING, ekstra CONNECT-pakker=1  (normal retry-kadence, ingen storm)

Men ikke i tilstanden connecting — paho's indledende ConnectRetry-løkke, som for watchdoggen ser fuldstændig ens ud:

ConnectRetry initial loop: IsConnected=true IsConnectionOpen=false
Connect() in status=connecting: Error() = "status can only transition to connecting from disconnected"

Den nye kode logger derfor WATCHDOG force-reconnect Connect() failed: … ved hver udløsning i det vindue. Funktionelt harmløst — paho's løkke kører videre, og det er netop den ønskede adfærd — men det er et misvisende operatørsignal i en rettelse, hvis formål er at gøre reconnect-adfærd diagnosticerbar. SHOULD-FIX: skeln mellem den fejl og en ægte fejl, eller nedgrader den til debug.

Status på reviewet — ærligt

Det uafhængige review blev afbrudt af en ugentlig rate-limit, ikke af et fund. Harnessen nåede at blive skrevet og de ovenstående målinger at blive kørt; jeg har selv kørt dem igen og rapporterer dem her. Det, der ikke nåede at blive afsluttet:

  • Storm-raten (CONNECT-pakker pr. tidsenhed ved gentagne udløsninger) — opgaven kræver den eksplicit. Delresultat: en bevidst ustrubbet concurrency-test (16 goroutiner × 5 s uden throttle) gav 3267 CONNECT-pakker uden deadlock, men det siger intet om den rigtige rate, fordi forceReconnectThrottle er sat ud af spil i den test.
  • Mutationstest af IsConnectionOpen-garden.
  • go test -race ./... på hele pakken for denne branch.

Alle reviewerens probe-filer er fjernet igen; git status er ren.

Hvorfor denne PR er PARKERET, ikke merget

Opgavens regler kræver staging før merge for MQTT-/reconnect-ændringer, og det er en race mod paho's baggrundsløkke og en rigtig broker. Staging kan ikke nås herfra: docker-dæmonen kører ikke, ~/meshcore-staging-data findes ikke, ingen ~/.ssh/config, ingen remote docker-context, og deploy-jobbet kører på den self-hostede runner [self-hosted, meshcore-runner-2], som er fork-guarded fra. Docker blev bevidst ikke startet: containerne har restart: unless-stopped, så en dæmonstart kunne rejse en ingestor mod en live broker.

Konkret testplan ligger i STAGING-TESTPLAN.md under "#29". Det vigtigste punkt: stop staging-brokeren (ikke produktionens), lad watchdoggen udløse mens paho retry'er, og mål tiden til genforbindelse når brokeren kommer op igen — det er præcis den måling, der ovenfor går fra "aldrig inden for 60 s" til "11 ms".

🤖 Generated with Claude Code

@dborup

dborup commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Tillæg: storm-raten er nu målt — opgavens eksplicitte krav

A/B-kørslen blev færdig. To tilføjelser til tallene ovenfor.

Storm-rate, udløst hvert 200. ms i ~10 s mod en nedlagt broker:

udløsninger CONNECT-pakker rate slut-tilstand
gammel 23 0 0,00/s DEAD — intet retry'er
ny 50 10 0,99/s RETRYING
baseline (ingen force-reconnect overhovedet) — 10 1,00/s RETRYING

Den nye kode rammer paho's egen naturlige retry-kadence præcist — 0,99 mod 1,00 CONNECT/s. Ingen storm: force-reconnect lægger reelt intet oven i det, paho ville gøre alligevel, selv når watchdoggen udløser 50 gange. Og den gamle kode stormer heller ikke — den gør det modsatte: 23 udløsninger gav nul forbindelsesforsøg, fordi retry-løkken allerede var revet ned.

Prod-timings, Case B (fuld ConnectTimeout/MaxReconnectInterval):

40 s efter force-reconnect broker kommer op igen
gammel DEAD, 0 CONNECT-pakker udstedt recovered = false efter 1m0,002s, i alt 0 CONNECT
ny RETRYING, 4 CONNECT recovered = true efter 19,006s, i alt 5 CONNECT

Det er den 100+ minutters reconnect-fejl, PR-teksten beskriver, reproduceret i lille skala: uden rettelsen kommer klienten aldrig tilbage af sig selv.

🤖 Generated with Claude Code

@dborup

dborup commented Sep 22, 2026

Copy link
Copy Markdown
Owner

PARKERET — uafhængigt review fandt en BLOCKER på testdækningen

Implementeringen er korrekt og en stor, målt forbedring. Men den egenskab, PR'en findes for at beskytte, er ikke pinned af nogen test — og det var netop den gate, denne runde skulle lukke.

Blockeren

En compile-valid mutant (M18) holder buildForceReconnectFn byte-identisk på nær ét: den flytter nedrivningen ind i en goroutine i retry-grenen —

go func(){ time.Sleep(50*time.Millisecond); client.Disconnect(250) }()

Det er den nærliggende "lad os ikke blokere watchdoggen på Disconnect"-refaktorering. Resultat:

PR'ens 3 nye tests grønne
Hele cmd/ingestor-suiten (CI's kald) grøn
-race -count=5 grøn, 0 DATA RACE
Mod en rigtig broker 0 CONNECT-pakker på 3 s, recovered=false efter 60 s, permanent DEAD

Altså: mutanten genskaber præcis den Kpa-clawbot#1897-produktionsfejl, PR'en retter, og ingen test siger fra.

M15 er værre: go func(){ client.Disconnect(250) }() ubetinget. Den overlever PR'ens tests og hele suiten, og knækker begge cases — Case A healed=false efter 20 s og Case B permanent død. Kun -race fanger den (3 DATA RACE fra fakeClient.callOrder) — og CI kører cmd/ingestor uden -race (.github/workflows/deploy.yml:73, mod :64 hvor cmd/server får det). CI fanger derfor ingen af dem.

8 af 18 mutanter overlever, alle compile-valide (alle 19 bestod både go build ./... og go test -c — nul ugyldige mutanter).

Hvorfor testene ikke fanger det

De asserterer en kaldsekvens mod en fake, hvilket er strukturelt. Alt, der bevarer [IsConnectionOpen, Disconnect, Connect] og tilføjer asynkronitet, slipper igennem. fakeToken.Wait() returnerer ubetinget true, og fakeClient.Disconnect(quiesce uint) kasserer quiesce — så timing og blokering er uobserverbare per konstruktion. Filens egen kommentar formulerer den egenskab, dens fake gør utestbar.

Jeg kørte selv fire mutationer først (fjern garden, invertér den, fjern Connect(), fjern fejltjekket) og fik 4/4 dræbt. Det var utilstrækkeligt: mine var strukturelle sletninger, som kaldsekvensen fanger. Reviewerens bevarer sekvensen og tilføjer asynkronitet. Min egen mutationstest gav altså falsk tryghed.

Hvad der lukker den

Én integrationstest med en rigtig paho-klient mod en in-process broker, der asserterer: efter én force-reconnect mod en nedlagt broker bliver der ved med at komme CONNECT-pakker, og klienten selvhelbreder når brokeren vender tilbage — uden et andet trigger. Den ene test dræber M1, M2, M9, M15 og M18. Der findes ingen in-process broker-helper i repoet i dag (~250 linjer), men der er præcedens for rigtige paho-tests i cmd/ingestor/main_test.go:958 og :986.

Implementeringen selv er bekræftet god

Alt det adfærdsmæssige består — målt mod en rigtig broker, og i overensstemmelse med min egen uafhængige kørsel:

ny gammel
Case A (ægte halvåben, Kpa-clawbot#1335) healed 7 ms, 1 CONNECT healed 7 ms, 1 CONNECT — identisk
Case B (broker nede) selvhelbredt på 2,003 s uden andet trigger recovered=false efter 60 s, permanent DEAD
Diskriminator under retry IsConnectionOpen sand i 0 af 341 samples —
Storm (parret, samme vindue) 0,26 CONNECT/s = 1,00× baseline 0,00/s (retry-løkken er død)
Throttle 2000 stall-kanter → præcis 11 kald —
Goroutine-delta +0 i både connected- og retrying-regime —
Fuld -race grøn, 322,886 s, 0 races —

Hver bærende påstand i PR-kommentaren er efterprøvet mod paho v1.5.0-kildekoden og holder.

Revieweren rapporterede i øvrigt selv en fejl i sit eget harness: sekventielle kørsler viste først "3,00× storm", men omvendt rækkefølge inverterede resultatet — det var paho's backoff-vækst hen over vinduet, ikke koden. Det parrede design fjerner artefakten.

Øvrige fund (ikke-blokerende)

  • main.go:582-584 er forkert på ét bærende punkt. "Connect() alone is a safe no-op per paho when a retry is already under way" gælder i status reconnecting, men ikke i connecting — som samme kommentar selv nævner som retry-tilstand. Målt: 5/5 triggers i connecting loggede Connect() failed: status can only transition to connecting from disconnected, med 0 ekstra CONNECT-pakker. Bundet til 1 linje/min/kilde, men det er ops-støj under præcis den nedetid, hvor operatøren læser logs.
  • Residual TOCTOU på main.go:587-589 — målt selvhelbredende, ikke fatal.
  • Pre-eksisterende shutdown-panic i mqtt_watchdog.go:676-679 (urørt af denne PR): panic: send on closed channel ved SIGTERM, hvis en force-reconnect er i flight. Denne PR gør det strengt bedre. Bør oprettes separat.

Status

PARKERET. Integrationssporet fortsætter uden #29 i rækkefølgen #49 → #26 → #47. PR'en er ikke ændret, branchen er urørt, og den kan optages igen, så snart integrationstesten ovenfor findes.

🤖 Generated with Claude Code

dborup and others added 2 commits September 26, 2026 06:54
…t a real paho client

The fake-client tests only assert the IsConnectionOpen/Disconnect/Connect
call sequence. A Disconnect moved into a goroutine keeps that sequence and
still kills paho's retry loop (review mutants M15/M18), and CI runs the
ingestor suite without -race, so nothing caught it.

New tests drive the production watchdog action (maybeForceReconnect ->
buildForceReconnectFn) against a real paho client built by buildMQTTOpts
and a small in-test MQTT broker, counting CONNECT packets at the broker:

- broker down, paho retrying, watchdog fires three times: CONNECTs keep
  arriving and the client reconnects on its own when the broker returns;
- half-open socket (IsConnectionOpen true, broker silent): the old socket
  is dropped and a new session comes up;
- paho stopped (disconnected, Kpa-clawbot#1749 escalation): force-reconnect starts a
  fresh connect without blocking while the broker is down.

CI: add a separate -race pass for the ForceReconnect tests only; the full
ingestor suite stays without -race. Fork guards untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0133zLreBhuYtXESMXVBfqno

dborup commented Sep 26, 2026

Copy link
Copy Markdown
Owner

Review feedback addressed (commit cb7dc51)

Closes the test BLOCKER from the 2026-09-22 review. Before that, master 7f6f410f was merged in with an ordinary merge commit (947b4cbe, tree 3d80b5f9, conflict-free). main.go is unchanged: this commit only adds tests and a CI step.

  1. Behavioural test against a real paho client. New file cmd/ingestor/mqtt_force_reconnect_paho_test.go. It uses a small in-test MQTT broker (a net.Listener built on paho's own packets package, no new modules). The client is built with buildMQTTOpts, so AutoReconnect, ConnectRetry and the 30 s keepalive match production; only the timeouts are shortened. Each trigger goes through the production watchdog action (maybeForceReconnect → buildForceReconnectFn), and CONNECT packets are counted at the broker:

  2. Mutants now killed. 19 compile-valid mutants of buildForceReconnectFn. The review's full catalogue isn't in the PR, so this set is rebuilt; M15 and M18 were built from the review's own description.

    Mutant Before (old tests) After, new tests only, without -race After, all ForceReconnect tests
    review M18: else { go func(){ time.Sleep(50ms); Disconnect(250) }() } survives (whole suite, 116 s) killed (retry loop + bug(mqtt): watchdog goroutine goes completely silent — 3 sources stalled 75 min, zero WATCHDOG log lines, no force-reconnect Kpa-clawbot/CoreScope#1749) killed
    review M15, variant: else { go func(){ Disconnect(250) }() } survives (whole suite) killed killed
    review M15, literal: go func(){ Disconnect(250) }() unconditionally killed by fake (order) killed (all 3 tests) killed
    Wait() before Error() survives (whole suite) killed (bug(mqtt): watchdog goroutine goes completely silent — 3 sources stalled 75 min, zero WATCHDOG log lines, no force-reconnect Kpa-clawbot/CoreScope#1749: fn blocks) killed
    Disconnect(0) survives (whole suite) killed (half-open: dead client) killed
    pre-fix: stop watchdog force-reconnect from racing paho's own retry loop Kpa-clawbot/CoreScope#1897 code / no guard / || true / IsConnected() as guard killed by fake killed (retry loop dies) killed
    invert guard, drop Disconnect, drop Connect, Connect only in guard, guarded Disconnect async killed by fake killed killed
    drop error log / log without tag killed by fake survives (not behavioural) killed by fake
    whole body async / Connect async killed by fake survives (equivalent) killed by fake
    Disconnect(5000) survives survives survives: equivalent

    Totals: 5 of 19 survived before, 1 of 19 after. The one left, Disconnect(5000), is equivalent: Disconnect returns as soon as the disconnect is done (from connected that is immediate), so quiesce only caps the worst case, and the watchdog already runs the function in a goroutine. Every kill of M15, M18, the pre-fix: stop watchdog force-reconnect from racing paho's own retry loop Kpa-clawbot/CoreScope#1897 code, Disconnect(0) and Wait() was repeated 5/5.

    With -race the old fake tests do catch M15/M18 here (5–8 DATA RACE on fakeClient.callOrder), but CI didn't run -race for the ingestor.

  3. Stability (correct code): -count=20 without -race: 60/60 PASS. -count=10 with -race: 30/30 PASS, 0 DATA RACE. -count=5 in parallel with the whole suite: 15/15 PASS. The whole cmd/ingestor suite: ok (115.7 s). go vet: clean.

  4. CI change (.github/workflows/deploy.yml): new step Race-check Go ingestor force-reconnect right after the ingestor test step. It runs go test -race -count=1 -timeout 5m -run 'ForceReconnect' ./... in cmd/ingestor, which covers 10 tests and takes about 6 s locally. The full ingestor suite still runs without -race. Fork guards are untouched.

The PR stays non-draft and is neither marked ready nor merged.


Generated by Claude Code

@dborup
dborup merged commit 8a7a69e into master Sep 26, 2026
6 checks passed
adminopenclaw8-sketch pushed a commit that referenced this pull request Sep 26, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants