mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 16:07:55 +00:00
# Partial fix for #1749 — MQTT watchdog escalation + panic recovery +
tick exposure
Red commit: 9912bbb3e9 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions/runs?branch=fix%2Fissue-1749)
## Problem
Production CoreScope v3.9.1 (and a more recent prod recurrence on
2026-06-30 with the wcmesh source on `ssl://mqtt2.wcmesh.com:8883`)
showed two distinct watchdog failure modes:
1. **Per-source paho machinery dies silently.** `IsConnectedFn` returns
false; paho's `SetAutoReconnect(true)` never retries. The watchdog's
`processLivenessTransition` deliberately stays silent on
`LivenessDisconnected`, trusting paho to recover — so there is no
escalation path when that trust is misplaced.
2. **Watchdog goroutine death.** Three sources went silent within ~60s
of each other; no `WATCHDOG` log lines for 75 min. The most plausible
single point of failure is a panic inside `emit` (e.g. a blocked log
pipe) killing the loop with no defer/recover.
## Changes
**`cmd/ingestor/mqtt_watchdog.go`**
- Added `disconnectedReconnectMultiplier = 5` constant.
- Added `DisconnectedSinceUnix int64` (atomic) on `SourceLivenessState`.
Stamped on the first tick the source is observed disconnected; cleared
on any non-disconnected tick.
- `processLivenessTransition` now escalates: when `(now -
DisconnectedSinceUnix) > multiplier × threshold`, emits a `WATCHDOG
ESCALATION` WARN and calls `maybeForceReconnect` (subject to existing
`forceReconnectThrottle`). Distinct from the existing `LivenessStalled`
path so operators can grep escalation events independently.
- Added package-level `watchdogLastTickUnix atomic.Int64` +
`WatchdogLastTickUnix()` getter. The loop stamps it BEFORE per-source
processing — a wedged source-handler does not freeze the clock for an
external observer.
- `runLivenessWatchdogLoop` wraps each per-source
`processLivenessTransition` call in `func() { defer recover; ... }()` so
a panic in `emit` (or in any per-source code path) is logged and
skipped, not fatal. The loop continues to the next source and the next
tick.
**`cmd/ingestor/stats_file.go`**
- Added `WatchdogLastTickUnix int64` field on `IngestorStatsSnapshot`
(additive, `omitempty`); populated from `WatchdogLastTickUnix()` each
stats tick.
**`cmd/server/mqtt_status.go`**
- `MqttStatusResponse` gains `WatchdogLastTickUnix int64` (additive,
`omitempty`) sourced from the ingestor stats file; surfaced via `GET
/api/mqtt/status`.
**`config.example.json`**
- No new config field added — the multiplier is a code constant (5×) per
the issue's "N×threshold (e.g. 5×)" recommendation. The active
per-source `threshold` is the 5-minute scan threshold hard-coded at the
sole `runLivenessWatchdog` callsite (`cmd/ingestor/main.go:460`:
`runLivenessWatchdog(60*time.Second, 5*time.Minute)`), so escalation
fires at ~25 minutes (5 × 5min) of continuous disconnect — plus a
deterministic per-source jitter of 0..30s (#1810 round-1, see Taleb #4)
to avoid synchronized escalation across N sources sharing an upstream
broker outage.
## Acceptance (#1749)
- [x] Persistent `LivenessDisconnected` > N×threshold → force-reconnect
+ WARN
- [x] Watchdog goroutine liveness clock exposed (`WatchdogLastTickUnix`
in `/api/mqtt/status`)
- [x] Test: `IsConnectedFn` false for >5×threshold → assert
`ForceReconnectFn` invoked at least once
- [x] Test: panic in `emit` → assert loop recovers and continues ticking
## Test plan
- 4 new tests in `cmd/ingestor/mqtt_watchdog_1749_test.go` (all RED on
master, GREEN on this PR).
- Existing watchdog tests (`mqtt_watchdog_force_reconnect_test.go`,
`mqtt_reconnect_test.go`, r1/r2/m1 suites) continue to pass — the
escalation path is additive.
## Preflight
- TDD: red commit pushed and asserted to fail BEFORE green commit
landed.
- PII grep: clean on diff and PR body.
- Worktree: `_wt-fix-1749` on branch `fix/issue-1749`.
---------
Co-authored-by: corescope-bot <bot@corescope.dev>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: bot <bot@local>
165 lines
6.7 KiB
Go
165 lines
6.7 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
// mqttBrokerSchemes is the set of broker URL schemes whose embedded
|
||
// `user:pass@host` credentials we want to redact. We URL-parse for these
|
||
// (defense vs. passwords containing `@`); other strings fall through to
|
||
// the legacy regex pass for embedded user:pass occurrences in free-form
|
||
// error strings.
|
||
var mqttBrokerSchemes = map[string]bool{
|
||
"mqtt": true, "mqtts": true, "tcp": true, "ssl": true, "ws": true, "wss": true,
|
||
}
|
||
|
||
// mqttBrokerURLRe locates a broker URL (with credentials) embedded inside
|
||
// a larger free-form string — e.g. an error message that quotes the
|
||
// failing broker. Each match is fed through url.Parse + redaction. We
|
||
// match greedily up through the LAST `@` followed by a host-shaped token
|
||
// so passwords containing `@` are not truncated (#1682 adversarial r1).
|
||
//
|
||
// Go's RE2 has no lookahead; we capture the host tail and emit it
|
||
// unchanged in the replacement.
|
||
var mqttBrokerURLRe = regexp.MustCompile(`(?i)(?:mqtt|mqtts|tcp|ssl|ws|wss)://[^\s]*`)
|
||
|
||
// maskBrokerURL returns the broker URL with any inline password redacted.
|
||
// `mqtt://user:secret@host:1883` -> `mqtt://user:****@host:1883`.
|
||
// `mqtt://user:p@ss@host` -> `mqtt://user:****@host` (password with `@`).
|
||
// URLs without inline credentials are returned unchanged.
|
||
//
|
||
// Primary strategy: url.Parse — handles passwords with `@`, `:`, etc.
|
||
// Fallback: regex sweep for free-form strings (e.g. error messages that
|
||
// quote a URL fragment but aren't standalone-parseable).
|
||
func maskBrokerURL(s string) string {
|
||
if s == "" {
|
||
return s
|
||
}
|
||
// Fast path: the whole string is the broker URL.
|
||
if masked, ok := redactBrokerURL(s); ok {
|
||
return masked
|
||
}
|
||
// Fallback: free-form string (e.g. error message) containing a URL.
|
||
// Find embedded broker URLs and redact each in-place.
|
||
return mqttBrokerURLRe.ReplaceAllStringFunc(s, func(m string) string {
|
||
if out, ok := redactBrokerURL(m); ok {
|
||
return out
|
||
}
|
||
return m
|
||
})
|
||
}
|
||
|
||
// redactBrokerURL parses s as a URL and, if it has an mqtt-family scheme
|
||
// with userinfo containing a password, returns the URL with the password
|
||
// replaced by `****`. Returns ok=false when s is not such a URL.
|
||
func redactBrokerURL(s string) (string, bool) {
|
||
u, err := url.Parse(s)
|
||
if err != nil || u.Scheme == "" || u.User == nil {
|
||
return s, false
|
||
}
|
||
if !mqttBrokerSchemes[strings.ToLower(u.Scheme)] {
|
||
return s, false
|
||
}
|
||
if _, hasPass := u.User.Password(); !hasPass {
|
||
return s, false
|
||
}
|
||
// Re-assemble manually rather than via url.UserPassword + u.String()
|
||
// because the latter percent-encodes the `*` mask token into `%2A`,
|
||
// defeating the user-visible redaction marker. We only need to swap
|
||
// the userinfo segment of the original string.
|
||
hostAndAfter := s
|
||
if idx := strings.LastIndex(s, "@"); idx >= 0 {
|
||
hostAndAfter = s[idx+1:]
|
||
}
|
||
// Preserve original scheme casing (url.Parse lowercases u.Scheme).
|
||
schemeEnd := strings.Index(s, "://")
|
||
if schemeEnd < 0 {
|
||
return s, false
|
||
}
|
||
return s[:schemeEnd] + "://" + u.User.Username() + ":****@" + hostAndAfter, true
|
||
}
|
||
|
||
// MqttSourceStatus is the per-MQTT-source status row surfaced via
|
||
// /api/mqtt/status. Mirrors the on-disk shape the ingestor publishes
|
||
// (cmd/ingestor SourceStatusSnapshot) but with the broker URL credentials
|
||
// redacted before serving — operators must not see the broker password
|
||
// in the API response (#1043 acceptance criterion).
|
||
type MqttSourceStatus struct {
|
||
Name string `json:"name"`
|
||
Broker string `json:"broker"`
|
||
Connected bool `json:"connected"`
|
||
LastConnectUnix int64 `json:"lastConnectUnix"`
|
||
LastDisconnectUnix int64 `json:"lastDisconnectUnix"`
|
||
LastPacketUnix int64 `json:"lastPacketUnix"`
|
||
ConnectCount int64 `json:"connectCount"`
|
||
DisconnectCount int64 `json:"disconnectCount"`
|
||
PacketsTotal int64 `json:"packetsTotal"`
|
||
PacketsLast5m int64 `json:"packetsLast5m"`
|
||
LastError string `json:"lastError,omitempty"`
|
||
}
|
||
|
||
// MqttStatusResponse is the JSON envelope returned by /api/mqtt/status.
|
||
type MqttStatusResponse struct {
|
||
Sources []MqttSourceStatus `json:"sources"`
|
||
SampleAt string `json:"sampleAt"`
|
||
// WatchdogLastTickUnix (#1749) is the unix-seconds timestamp of the
|
||
// most recent ingestor watchdog tick. Surfaced so external monitoring
|
||
// can detect a wedged watchdog goroutine (value older than ~2× the
|
||
// scan interval — typically 60s — means the watchdog itself died,
|
||
// not just one source). 0 / omitted: ingestor has never ticked yet
|
||
// or is running an older build that did not publish this field.
|
||
WatchdogLastTickUnix int64 `json:"watchdogLastTickUnix,omitempty"`
|
||
// WatchdogPanicCount (#1810 round-1) is the running total of
|
||
// recovered panics inside the ingestor's watchdog per-source work
|
||
// IIFE. Surfaced alongside WatchdogLastTickUnix because the tick
|
||
// clock is stamped BEFORE per-source work — a loop panicking on
|
||
// every source still advances the tick and looks healthy by tick
|
||
// alone. A rapidly-growing value means the watchdog is alive but
|
||
// per-source processing is broken. 0 / omitted: no recovered
|
||
// panics OR older ingestor build.
|
||
WatchdogPanicCount int64 `json:"watchdogPanicCount,omitempty"`
|
||
}
|
||
|
||
// ingestorMqttStatusEnvelope is the partial shape the server decodes from
|
||
// the ingestor stats file (additive — older ingestors omit the field).
|
||
type ingestorMqttStatusEnvelope struct {
|
||
SampledAt string `json:"sampledAt"`
|
||
SourceStatuses []MqttSourceStatus `json:"source_statuses"`
|
||
WatchdogLastTickUnix int64 `json:"watchdogLastTickUnix"`
|
||
WatchdogPanicCount int64 `json:"watchdogPanicCount"`
|
||
}
|
||
|
||
// handleMqttStatus serves GET /api/mqtt/status. Reads the ingestor stats
|
||
// file, masks broker-URL passwords, and returns the per-source status
|
||
// list. Returns an empty list (200 OK) when the stats file is missing
|
||
// or unparseable — the UI panel renders a "no data yet" state.
|
||
func (s *Server) handleMqttStatus(w http.ResponseWriter, r *http.Request) {
|
||
resp := MqttStatusResponse{Sources: []MqttSourceStatus{}, SampleAt: ""}
|
||
data, err := os.ReadFile(IngestorStatsPath())
|
||
if err != nil {
|
||
writeJSON(w, resp)
|
||
return
|
||
}
|
||
var env ingestorMqttStatusEnvelope
|
||
if err := json.Unmarshal(data, &env); err != nil {
|
||
writeJSON(w, resp)
|
||
return
|
||
}
|
||
resp.SampleAt = env.SampledAt
|
||
resp.WatchdogLastTickUnix = env.WatchdogLastTickUnix
|
||
resp.WatchdogPanicCount = env.WatchdogPanicCount
|
||
for _, src := range env.SourceStatuses {
|
||
src.Broker = maskBrokerURL(src.Broker)
|
||
// Broker libraries occasionally quote the failing URL in the
|
||
// error string — redact there too as defense-in-depth.
|
||
src.LastError = maskBrokerURL(src.LastError)
|
||
resp.Sources = append(resp.Sources, src)
|
||
}
|
||
writeJSON(w, resp)
|
||
}
|