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();