refactor(heartbeat): extract protocol-agnostic HeartbeatScheduler; MeshCore adopts (#3962)#4005
Conversation
#3962) Extracts interval management, start/stop idempotency, in-flight guard, and pre+post-await connected-gate into a standalone scheduler with a narrow capability interface (probe/isConnected/onSuccess/onFailure). All protocol semantics (wire-op, failure counting, reconnect trigger) stay in the caller. 9 fake-timer unit tests cover every gate in the spec's behavior table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
…er (#3962) MeshCore adoption of the new HeartbeatScheduler leaf: - startHeartbeat() constructs a per-instance HeartbeatScheduler and delegates timer/gating; shouldReconnect=true and the "Heartbeat started" log stay in the manager - stopHeartbeat() calls scheduler.stop() and nulls the instance - new heartbeatProbe(timeoutMs) wraps sendCommand('get_device_time'); throws on failure so the scheduler's onFailure callback carries the error message - new onHeartbeatOk(latencyMs) resets counter, sets lastSuccessAt, emits heartbeat_ok - runHeartbeatProbe() deleted; heartbeatTimer/heartbeatProbeInFlight fields removed - recordHeartbeatFailure() and beginReconnect() unchanged (scope firewall) Adds integration test: N consecutive probe failures → heartbeat_failed×N and connectionState='reconnecting', proving manager-side failure→reconnect coupling survived extraction. Also updates REMEDIATION_EPIC.md: check off 2.1 and add phase log for 2.1+2.2a. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
|
Claude finished @Yeraze's task —— View job PR Review
Overall this is a well-structured, clean extraction. The Potential Bug:
|
…legate arming (#3962) (#4009) * feat(remediation): extract CronOrIntervalScheduler leaf primitive (#3962 Phase 2 WP1) New protocol-agnostic scheduling primitive that owns only the arming mechanism (cron-vs-interval), mirroring HeartbeatScheduler from 2.2a. - CronOrIntervalScheduler: ScheduleMode discriminated union (cron/interval), idempotent stop+rearm start(), running getter, onTick rejection guard (new Promise constructor catches sync throws), validateCron/scheduleCron delegation from utils/cronScheduler.ts - 14 fake-timer unit tests covering: interval fires/stops, stop+rearm, idempotent stop, running getter, async rejection caught, sync throw caught, cron armed/disarmed, invalid cron returns false + warns + no fallback, stop+rearm on cron, idempotent cron stop Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n * refactor(meshtastic): delegate announce arming to CronOrIntervalScheduler (#3962 Phase 2 WP2) Replace the copy-pasted cron/interval arming skeleton in startAnnounceScheduler() with delegation to CronOrIntervalScheduler. Changes: - Remove announceInterval + announceCronJob fields; add announceScheduler - startAnnounceScheduler(): read settings → build ScheduleMode → construct scheduler → start(); on-start spam-protect + 30s delay block left intact - Cleanup path (userDisconnect): announceScheduler.stop() - Public signatures unchanged: startAnnounceScheduler, restartAnnounceScheduler, setAnnounceInterval, sendAutoAnnouncement, previewAnnouncementMessage Behavior preserved (§6 of spec): default cron '0 */6 * * *', default 6h interval, invalid cron → error log + no arm + no fallback, on-start 30s delay + <1h spam-protect, sendAutoAnnouncement untouched, NodeInfo follow-up untouched, restart wiring via settingsRoutes unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n * refactor(meshcore): delegate announce arming to CronOrIntervalScheduler (#3962 Phase 2 WP3) Replace the copy-pasted cron/interval arming skeleton in startAutoAnnounce() / stopAutoAnnounce() with delegation to CronOrIntervalScheduler. Changes: - Remove autoAnnounceTimer + autoAnnounceCron fields; add announceScheduler (CronOrIntervalScheduler | null); autoAnnounceLastRunAt and autoAnnounceAdvertTimer are kept — the advert timer is a one-shot follow-up burst, not the scheduler's concern - startAutoAnnounce(): read settings → build ScheduleMode → construct scheduler → start(); invalid cron → warn + null + return (no fallback) - stopAutoAnnounce(): scheduler.stop() + advert timer clear (both paths) - getAutoAnnounceStatus(): enabled = announceScheduler?.running ?? false - Public signatures unchanged: startAutoAnnounce, stopAutoAnnounce, runAutoAnnounceCycle, getAutoAnnounceStatus, previewAnnouncementMessage Behavior preserved (§6 of spec): default cron '0 */6 * * *', Math.max(1,…) interval clamp, invalid cron → warn + no arm + no fallback, tick runs runAutoAnnounceCycle('cron'|'interval'), advert timer cleared in stop, deviceType-REPEATER gate and region-scope override (#3833) untouched, restart via meshcoreRoutes POST → startAutoAnnounce unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n * docs(epic): check off 2.2a/2.2b; log premise correction and task outcome (#3962) - Mark 2.2a DONE with log entry: PR #4005 merged (HeartbeatScheduler leaf) - Mark 2.2b DONE; log premise correction: plan's "autoAnnounceService with adapters" not supported — shared surface is the arming skeleton only (~40 LOC); reduced to CronOrIntervalScheduler leaf primitive; follow-ups noted for timer-trigger and distance-delete adoption Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
Task 2.2a of the remediation plan (#3962, Phase 2) — with a documented premise correction.
The plan assumed heartbeat/status-probing was duplicated across both managers. Investigation showed otherwise: Meshtastic's "heartbeat" is a fire-and-forget keepalive push living in the transport layer (
tcpTransport.ts), MeshCore's is a manager-owned request/response probe loop with failure counting feeding its reconnect machinery, and MQTT has none. There is no shared subsystem to unify — so this PR does the honest minimal version instead of a forced abstraction:src/server/services/heartbeatScheduler.ts— a protocol-agnostic leaf utility owning only the genuinely reusable mechanism: interval management, idempotent start/stop, in-flight guard, and connected-gates pre- and post-await on both the resolve and throw paths (drops late results racing a teardown). Takes a narrow capability interface (probe/isConnected/onSuccess/onFailure) — deliberately notISourceManager.startHeartbeat/stopHeartbeatbecome thin delegates; the probe becomes a cleanheartbeatProbe()wire op. The failure counter,heartbeat_ok/heartbeat_failedevents, threshold logic, andbeginReconnect()stay in the manager — the deliberate scope firewall against the reconnect state machine (Phase 4.2's job). Socket-drop detection ([BUG] MeshCore: Cannot connect to virtual node #3705) untouched.heartbeat_failed×3 →connectionState='reconnecting'.A third source type gets liveness probing for free by supplying four callbacks.
Verification
success: true); typecheck clean; test-typecheck at 282 baseline;npm run lint:cigreen.Also checks off 2.1 in the epic status doc.
Refs #3962
🤖 Generated with Claude Code
https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n