From 05e31eadf03ffd25a11f6e3244b60f6ddf1462e5 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:46:16 +0100 Subject: [PATCH 1/3] test(monitoring): synchronise canonical token host fixture GetMonitor starts polling concurrently, so monitor.mu does not protect the fixture host slice from State.GetSnapshot. Use the state-owned UpsertHost setter to match the reader lock while retaining all canonical-token diagnostics assertions. Addresses the fixture race reported in PR1943 rest-1; no production behaviour changes. Change-source: pulse-maintainer --- internal/monitoring/canonical_guardrails_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index ac7f80490..4398e9f8c 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -2683,8 +2683,9 @@ func TestDefaultOrgMonitorSharesCanonicalRuntimeTokenInventory(t *testing.T) { Scopes: []string{config.ScopeAgentExec}, }} config.Mu.Unlock() - monitor.mu.Lock() - monitor.state.Hosts = []models.Host{{ + // GetMonitor starts polling concurrently; host fixtures must use the + // state-owned lock, not monitor.mu, to synchronise with snapshots. + monitor.state.UpsertHost(models.Host{ ID: "agent-fresh-token", Hostname: "fresh-token-host", Status: "online", @@ -2692,8 +2693,7 @@ func TestDefaultOrgMonitorSharesCanonicalRuntimeTokenInventory(t *testing.T) { AgentVersion: "6.2.2", TokenID: "fresh-agent-token", CommandsEnabled: true, - }} - monitor.mu.Unlock() + }) diagnostics := monitor.GetAgentFleetDiagnostics("6.2.2", now) agent := requireAgentDiagnostic(t, diagnostics, "agent-agent-fresh-token") From 8033fa581a179fe152443cbf2863caa167478071 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:00:22 +0100 Subject: [PATCH 2/3] fix(monitoring): serialize delivery health alert projection Concurrent timer and queue callbacks could apply an older health snapshot after a newer one, hiding a new delivery failure or resurrecting a dismissed warning. Serialize the complete read/apply operation without holding the monitor or queue mutex across alert updates. Add isolated channel-controlled stale-clear and stale-raise regression cases. Removing the lock fails both final-state assertions; restored code passes 100 race-enabled focused repetitions. This does not qualify the integrated release candidate or clear unrelated adverse evidence. Change-source: pulse-maintainer Contract-Neutral: Restores monitoring contract extension point 21 immediate canonical delivery-warning reconciliation by serializing existing read/apply operations; no public API, verdict, throttle, alert identity, or agent-lifecycle contract changes. Existing isolated ordering regression tests cover both stale-clear and stale-raise outcomes. --- internal/monitoring/monitor.go | 3 +- internal/monitoring/system_alerts.go | 13 ++++- internal/monitoring/system_alerts_test.go | 69 +++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 11a370b76..31d60322f 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -1124,7 +1124,8 @@ type Monitor struct { deadManConfigMu sync.RWMutex deadManConfig notifications.DeadManConfig deadManConfigLoadErr error - lastDeliveryHealthCheck time.Time // throttles the notification-delivery system alert evaluation; guarded by mu + deliveryHealthProjectionMu sync.Mutex // serializes delivery-health reads and alert projection + lastDeliveryHealthCheck time.Time // throttles the notification-delivery system alert evaluation; guarded by mu configPersist *config.ConfigPersistence discoveryService *discovery.Service // Background discovery service activePollCount int32 // Number of active polling operations diff --git a/internal/monitoring/system_alerts.go b/internal/monitoring/system_alerts.go index f49b28f5e..4b6bdd1f9 100644 --- a/internal/monitoring/system_alerts.go +++ b/internal/monitoring/system_alerts.go @@ -57,7 +57,18 @@ func (m *Monitor) evaluateNotificationDeliveryAt(now time.Time, force bool) { return } - health := notificationMgr.DeliveryHealth() + m.projectNotificationDeliveryHealth(alertManager, notificationMgr.DeliveryHealth) +} + +// projectNotificationDeliveryHealth serializes the entire read/apply operation. +// Locking only the alert mutation lets a paused, older health read overwrite a +// newer reconciliation (either resurrecting a dismissed warning or hiding a +// new failure). Queue callbacks enter here after releasing the queue lock. +func (m *Monitor) projectNotificationDeliveryHealth(alertManager *alerts.Manager, readHealth func() notifications.DeliveryHealth) { + m.deliveryHealthProjectionMu.Lock() + defer m.deliveryHealthProjectionMu.Unlock() + + health := readHealth() if health.Healthy { alertManager.ClearSystemAlert(alerts.NotificationDeliveryAlertType) return diff --git a/internal/monitoring/system_alerts_test.go b/internal/monitoring/system_alerts_test.go index 435fd94c8..40d8168bc 100644 --- a/internal/monitoring/system_alerts_test.go +++ b/internal/monitoring/system_alerts_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/notifications" ) @@ -130,3 +131,71 @@ func TestEvaluateNotificationDeliveryIsSafeWithoutAMonitor(t *testing.T) { var m *Monitor m.evaluateNotificationDelivery(time.Now()) } + +// Hold the older snapshot between read and apply while a newer reconciliation +// tries to enter. Exercise both stale-clear and stale-raise failure modes +// without a database, queue workers, or notification destinations. +func TestProjectNotificationDeliveryHealthOrdersSnapshots(t *testing.T) { + healthy := notifications.ClassifyQueueHealth(map[string]int{}) + failed := notifications.ClassifyQueueHealth(map[string]int{string(notifications.QueueStatusDLQ): 1}) + for _, tc := range []struct { + name string + old, current notifications.DeliveryHealth + }{ + {"new_failure_survives_old_clear", healthy, failed}, + {"dismissal_survives_old_failure", failed, healthy}, + } { + t.Run(tc.name, func(t *testing.T) { + manager := alerts.NewManagerWithDataDir(t.TempDir()) + t.Cleanup(manager.Stop) + m := &Monitor{} + read := make(chan struct{}) + release := make(chan struct{}) + oldDone := make(chan struct{}) + go func() { + defer close(oldDone) + m.projectNotificationDeliveryHealth(manager, func() notifications.DeliveryHealth { + close(read) + <-release + return tc.old + }) + }() + <-read + // This assertion is independent of scheduling: the snapshot must + // already be protected before reading, not just when applying it. + if m.deliveryHealthProjectionMu.TryLock() { + m.deliveryHealthProjectionMu.Unlock() + t.Error("health read is not protected by the projection lock") + } + newRead := make(chan struct{}) + newDone := make(chan struct{}) + go func() { + defer close(newDone) + m.projectNotificationDeliveryHealth(manager, func() notifications.DeliveryHealth { + close(newRead) + return tc.current + }) + }() + select { + case <-newRead: + // Ensure the newer state applies before releasing the stale + // snapshot when checking the unprotected implementation. + <-newDone + t.Error("new health read overtook an unfinished projection") + case <-time.After(25 * time.Millisecond): + } + close(release) + <-oldDone + <-newDone + active := false + for _, alert := range manager.GetActiveAlerts() { + if alert.Type == alerts.NotificationDeliveryAlertType { + active = true + } + } + if active == tc.current.Healthy { + t.Errorf("delivery warning active = %v, latest health healthy = %v", active, tc.current.Healthy) + } + }) + } +} From f23553f82584b1d50a6923e56a33318d00a82e7a Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:40:26 +0100 Subject: [PATCH 3/3] Link the demo banner back to the install steps pulserelay.pro sends curious visitors to the public demo, and the demo was a dead end: once inside, the only way back to installing Pulse was the browser's history. The demo-mode banner now ends with "Run Pulse on your own hardware", opening the site's setup steps in a new tab. It is install guidance rather than an upsell, so it belongs in demo mode where commercial surfaces are otherwise hidden. The read-only notice keeps its own element so the existing text lookup and dismiss behaviour are unchanged. Verified on a DEMO_MODE=true mock backend behind Vite at 1280x800 and 390x844: the link renders after the notice, wraps cleanly on a phone, and carries target _blank with rel noopener noreferrer. Contract-Neutral: demo banner copy and link only; no public API, config, or contract surface changes --- frontend-modern/browser-verification.json | 29 ++++++------------- frontend-modern/src/components/DemoBanner.tsx | 16 +++++++++- .../components/__tests__/DemoBanner.test.tsx | 11 +++++++ 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 00f6b678a..557dd5566 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,20 +1,16 @@ { "version": 1, - "base_sha": "f4dc1e69a0e214dd0085c7696f42444b8fd91fa3", - "verified_at": "2026-09-06T16:58:13Z", + "base_sha": "df7eca7913c72f913fe196f37acec4d3b104c726", + "verified_at": "2026-09-06T19:40:26Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Login.tsx", - "frontend-modern/src/useAppRuntimeState.ts", - "frontend-modern/src/utils/localStorage.ts" + "frontend-modern/src/components/DemoBanner.tsx" ], "content_sha256": { - "frontend-modern/src/components/Login.tsx": "621f0b97d775aacaa521a3c72ed02db3fde5ad3eda06d55950a32107723eb44c", - "frontend-modern/src/useAppRuntimeState.ts": "5a1d343d92c441e1302dab129be7256dd9a287c6a60fb0e17c6f9fa13fe20900", - "frontend-modern/src/utils/localStorage.ts": "182ed45685228781fd32725db50611b1115f9ee4fb1af23aeedcf41ca707bdbc" + "frontend-modern/src/components/DemoBanner.tsx": "13fd8dea552d97f1df866438f7ba38eec01f25907dc68a0bec672e1793c3fc97" }, "routes": [ - "/" + "/proxmox/overview" ], "viewports": [ { @@ -27,18 +23,11 @@ } ], "states": [ - "demo-mode login page with the \"Signing you in to the demo\u2026\" status while the automatic sign-in is in flight", - "application shell after the automatic demo sign-in (Demo instance banner, Proxmox overview)", - "login form with printed demo credentials after an explicit Logout, session marker \"suppressed\"", - "login form still shown after a reload following the Logout", - "login form with the \"Invalid username or password\" error after a rejected automatic sign-in (401 stub), Sign in button enabled" + "demo-mode banner at 1280x800 with the read-only notice followed by the \"Run Pulse on your own hardware\" link", + "demo-mode banner at 390x844 wrapping onto two lines with the link visible and the dismiss control intact" ], "interactions": [ - "load / in a fresh browser context at 1280x800 and at 390x844; automatic POST /api/login with demo/demo, no typing", - "click the Logout control in the app header at both widths", - "reload the page after Logout", - "type demo/demo into the form and submit after Logout", - "open / in a second fresh context: automatic sign-in again", - "stub /api/login with 401 and load /: form fallback" + "load /proxmox/overview on a DEMO_MODE=true mock backend behind Vite in fresh contexts at both widths", + "read the link attributes from the live DOM: href https://pulserelay.pro/#setup, target _blank, rel noopener noreferrer" ] } diff --git a/frontend-modern/src/components/DemoBanner.tsx b/frontend-modern/src/components/DemoBanner.tsx index 7bfa9b6de..08c173968 100644 --- a/frontend-modern/src/components/DemoBanner.tsx +++ b/frontend-modern/src/components/DemoBanner.tsx @@ -3,6 +3,8 @@ import { createSignal, onMount, Show } from 'solid-js'; import { InlineNotice } from '@/components/shared/InlineNotice'; import { presentationPolicyIsDemoMode } from '@/stores/sessionPresentationPolicy'; +const DEMO_INSTALL_URL = 'https://pulserelay.pro/#setup'; + export function DemoBanner() { const [dismissed, setDismissed] = createSignal(false); @@ -29,7 +31,19 @@ export function DemoBanner() { dismissLabel="Dismiss demo banner" dismissTitle="Dismiss" > - Demo instance with mock data (read-only) + Demo instance with mock data (read-only) + + {/* The public demo is where pulserelay.pro sends curious visitors, and + without this it was a dead end: no way back except browser history. + Install guidance, not an upsell, so it stays in demo mode. */} + + Run Pulse on your own hardware + ); diff --git a/frontend-modern/src/components/__tests__/DemoBanner.test.tsx b/frontend-modern/src/components/__tests__/DemoBanner.test.tsx index abc1a361e..4d96920ed 100644 --- a/frontend-modern/src/components/__tests__/DemoBanner.test.tsx +++ b/frontend-modern/src/components/__tests__/DemoBanner.test.tsx @@ -57,6 +57,17 @@ describe('DemoBanner', () => { expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument(); }); + it('links the demo back to the install steps in a new tab', async () => { + presentationPolicyIsDemoModeMock.mockReturnValue(true); + + await renderBanner(); + + const link = screen.getByRole('link', { name: 'Run Pulse on your own hardware' }); + expect(link).toHaveAttribute('href', 'https://pulserelay.pro/#setup'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + it('stays hidden when demo mode is disabled', async () => { await renderBanner();