Compare commits

...
Author SHA1 Message Date
Yvan WangandGitHub 63959694de fix(hub): stop launching system updaters after manager shutdown (#1950) (#1951)
The staggered Initialize goroutine kept calling AddSystem after the hub
had been torn down, which spawned StartUpdater goroutines that later
dereferenced a nil DB inside PocketBase. Track a manager-level context
that RemoveAllSystems cancels, abort the staggered loop on shutdown,
and refuse AddSystem once the manager is stopped.
2026-08-17 13:59:42 -04:00
henrygd c1c1cd1bcb ui: fix temperature chart filtering 2026-04-20 20:45:16 -04:00
Yvan WangandGitHub cd9ea51039 fix(hub): use rfcEmail validator to allow IDN/Punycode email addresses (#1935)
valibot's email() action uses a domain-label regex that disallows the
-- sequence, so Punycode ACE labels like xn--mnchen-3ya.de (the ASCII
form of münchen.de) are incorrectly rejected.

Switching to rfcEmail() applies the RFC 5321 domain-label pattern,
which allows hyphens within labels and therefore accepts both standard
and internationalized domain names.
2026-04-18 12:23:14 -04:00
7 changed files with 49 additions and 6 deletions
+18 -1
View File
@@ -1,6 +1,7 @@
package systems
import (
"context"
"errors"
"fmt"
"time"
@@ -45,6 +46,8 @@ type SystemManager struct {
systems *store.Store[string, *System] // Thread-safe store of active systems
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
ctx context.Context // Cancelled when the manager is shutting down
cancel context.CancelFunc // Cancels ctx
}
// hubLike defines the interface requirements for the hub dependency.
@@ -60,10 +63,13 @@ type hubLike interface {
// NewSystemManager creates a new SystemManager instance with the provided hub.
// The hub must implement the hubLike interface to provide database and alert functionality.
func NewSystemManager(hub hubLike) *SystemManager {
ctx, cancel := context.WithCancel(context.Background())
return &SystemManager{
systems: store.New(map[string]*System{}),
hub: hub,
smartFetchMap: expirymap.New[smartFetchState](time.Hour),
ctx: ctx,
cancel: cancel,
}
}
@@ -103,7 +109,13 @@ func (sm *SystemManager) Initialize() error {
sleepTime := time.Duration(delta) * time.Millisecond
for _, system := range systems {
time.Sleep(sleepTime)
select {
case <-sm.ctx.Done():
// Abort if the manager has been shut down (e.g. test cleanup)
// to avoid starting updater goroutines against a torn-down hub. See #1950.
return
case <-time.After(sleepTime):
}
_ = sm.AddSystem(system)
}
}()
@@ -238,6 +250,11 @@ func (sm *SystemManager) onRecordAfterDeleteSuccess(e *core.RecordEvent) error {
// It validates required fields, initializes the system context, and starts the update goroutine.
// Returns error if a system with the same ID already exists.
func (sm *SystemManager) AddSystem(sys *System) error {
if sm.ctx.Err() != nil {
// Manager is shutting down; do not start new updater goroutines
// against a hub that may be torn down. See #1950.
return sm.ctx.Err()
}
if sm.systems.Has(sys.Id) {
return errSystemExists
}
+19
View File
@@ -3,9 +3,11 @@
package systems
import (
"context"
"testing"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/pocketbase/pocketbase/tools/store"
)
func TestCombinedData_MigrateDeprecatedFields(t *testing.T) {
@@ -157,3 +159,20 @@ func TestCombinedData_MigrateDeprecatedFields(t *testing.T) {
}
})
}
// TestAddSystemRefusedAfterShutdown verifies that AddSystem returns an error
// once the manager has been shut down, so the staggered Initialize starter
// cannot spawn updater goroutines against a torn-down hub. See #1950.
func TestAddSystemRefusedAfterShutdown(t *testing.T) {
sm := &SystemManager{systems: store.New(map[string]*System{})}
sm.ctx, sm.cancel = context.WithCancel(context.Background())
sm.cancel()
err := sm.AddSystem(&System{Id: "sys", Host: "127.0.0.1"})
if err == nil {
t.Fatalf("AddSystem returned nil error after shutdown")
}
if sm.systems.Has("sys") {
t.Fatalf("system was added to store after shutdown")
}
}
@@ -111,6 +111,11 @@ func (sm *SystemManager) SetSystemStatusInDB(systemID string, status string) boo
// TESTING ONLY: RemoveAllSystems removes all systems from the store
func (sm *SystemManager) RemoveAllSystems() {
// Signal shutdown first so any in-flight Initialize staggered-start goroutine
// stops adding new systems against a hub that is about to be torn down. See #1950.
if sm.cancel != nil {
sm.cancel()
}
for _, system := range sm.systems.GetAll() {
sm.RemoveSystem(system.Id)
}
@@ -104,7 +104,7 @@ export default function LineChartDefault({
isAnimationActive={false}
// stackId={dataPoint.stackId}
order={dataPoint.order || i}
// activeDot={dataPoint.activeDot ?? true}
activeDot={dataPoint.activeDot ?? true}
/>
)
})
@@ -17,7 +17,7 @@ import { toast } from "../ui/use-toast"
import { OtpInputForm } from "./otp-forms"
const honeypot = v.literal("")
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
const emailSchema = v.pipe(v.string(), v.rfcEmail(t`Invalid email address.`))
const passwordSchema = v.pipe(
v.string(),
v.minLength(8, t`Password must be at least 8 characters.`),
@@ -24,7 +24,7 @@ interface ShoutrrrUrlCardProps {
}
const NotificationSchema = v.object({
emails: v.array(v.pipe(v.string(), v.email())),
emails: v.array(v.pipe(v.string(), v.rfcEmail())),
webhooks: v.array(v.pipe(v.string(), v.url())),
})
@@ -120,7 +120,8 @@ export function TemperatureChart({
label: key,
dataKey: dataKeys[key],
color: colorMap[key],
opacity: strokeOpacity,
strokeOpacity,
activeDot: !filtered,
}
})
}, [sortedKeys, filter, dataKeys, colorMap])
@@ -134,7 +135,7 @@ export function TemperatureChart({
// label: `Test ${++i}`,
// dataKey: () => 0,
// color: "red",
// opacity: 1,
// strokeOpacity: 1,
// })
// }
// }
@@ -202,6 +203,7 @@ export function TemperatureChart({
return `${decimalString(value)} ${unit}`
}}
dataPoints={dataPoints}
filter={filter}
></LineChartDefault>
</ChartCard>
</div>