Compare commits

..

1 Commits

Author SHA1 Message Date
Yvan Wang
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
10 changed files with 130 additions and 171 deletions

View File

@@ -65,14 +65,6 @@ type deviceKey struct {
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
// isBridgePassthroughType reports whether the device type uses a USB bridge
// passthrough driver that may fail transiently (exit 2) due to wakeup data
// or other bridge-specific quirks. A single retry is attempted in CollectSmart.
func isBridgePassthroughType(deviceType string) bool {
dt := strings.ToLower(deviceType)
return strings.HasPrefix(dt, "jms56x") || strings.HasPrefix(dt, "jmb39x")
}
// Refresh updates SMART data for all known devices
func (sm *SmartManager) Refresh(forceScan bool) error {
sm.refreshMutex.Lock()
@@ -376,15 +368,9 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
Type string
Parse func([]byte) (bool, int)
}{
{Type: "nvme", Parse: func(output []byte) (bool, int) {
return sm.parseSmartForNvme(output, deviceInfo.Type)
}},
{Type: "sat", Parse: func(output []byte) (bool, int) {
return sm.parseSmartForSata(output, deviceInfo.Type)
}},
{Type: "scsi", Parse: func(output []byte) (bool, int) {
return sm.parseSmartForScsi(output, deviceInfo.Type)
}},
{Type: "nvme", Parse: sm.parseSmartForNvme},
{Type: "sat", Parse: sm.parseSmartForSata},
{Type: "scsi", Parse: sm.parseSmartForScsi},
}
deviceType := normalizeParserType(deviceInfo.parserType)
@@ -509,22 +495,15 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
// Check if device is in standby (exit status 2)
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 {
if hasExistingData {
// Device is in standby and we have cached data, keep using cache
return nil
}
// No cached data, retry without -n standby
// No cached data, need to collect initial data by bypassing standby
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel2()
args = sm.smartctlArgs(deviceInfo, false)
cmd = exec.CommandContext(ctx, sm.smartctlPath, args...)
cmd = exec.CommandContext(ctx2, sm.smartctlPath, args...)
output, err = cmd.CombinedOutput()
// Bridge passthrough drivers (jms56x, jmb39x) can fail siently due
// to wakeup data left by the bridge firmware. A second retry succeeds
// after the first attempt has cleared the bridge state.
if deviceInfo != nil && isBridgePassthroughType(deviceInfo.Type) {
if exitErr2, ok2 := errors.AsType[*exec.ExitError](err); ok2 && exitErr2.ExitCode() == 2 {
cmd = exec.CommandContext(ctx, sm.smartctlPath, args...)
output, err = cmd.CombinedOutput()
}
}
}
hasValidData := sm.parseSmartOutput(deviceInfo, output)
@@ -754,7 +733,6 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
}
if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil {
applyConfiguredMetadata(existingDev, configuredDevice)
delete(deviceIndexByName, configuredDevice.Name)
continue
}
@@ -858,12 +836,9 @@ func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool {
return false
}
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap.
// configuredType is the user-specified device type (e.g., "sat", "jms56x,0") from SMART_DEVICES.
// When non-empty, it overrides the disk type reported by smartctl in SmartData.DiskType so that
// updateSmartDevices can match entries by the configured composite (name, type) key.
// Returns hasValidData and exitStatus.
func (sm *SmartManager) parseSmartForSata(output []byte, configuredType string) (bool, int) {
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap
// Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
var data smart.SmartInfoForSata
if err := json.Unmarshal(output, &data); err != nil {
@@ -902,9 +877,6 @@ func (sm *SmartManager) parseSmartForSata(output []byte, configuredType string)
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type
if configuredType != "" {
smartData.DiskType = normalizeParserType(configuredType)
}
// get values from ata_device_statistics if necessary
var ataDeviceStats smart.AtaDeviceStatistics
@@ -978,7 +950,7 @@ func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *
return nil
}
func (sm *SmartManager) parseSmartForScsi(output []byte, configuredType string) (bool, int) {
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
var data smart.SmartInfoForScsi
if err := json.Unmarshal(output, &data); err != nil {
@@ -1013,9 +985,6 @@ func (sm *SmartManager) parseSmartForScsi(output []byte, configuredType string)
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type
if configuredType != "" {
smartData.DiskType = normalizeParserType(configuredType)
}
attributes := make([]*smart.SmartAttribute, 0, 10)
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
@@ -1113,12 +1082,9 @@ func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
return sm.darwinNvmeCapacity[serial]
}
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap.
// configuredType is the user-specified device type (e.g., "nvme") from SMART_DEVICES.
// When non-empty, it overrides the disk type reported by smartctl in SmartData.DiskType so that
// updateSmartDevices can match entries by the configured composite (name, type) key.
// Returns hasValidData and exitStatus.
func (sm *SmartManager) parseSmartForNvme(output []byte, configuredType string) (bool, int) {
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
// Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
data := &smart.SmartInfoForNvme{}
if err := json.Unmarshal(output, &data); err != nil {
@@ -1162,9 +1128,6 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, configuredType string)
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type
if configuredType != "" {
smartData.DiskType = normalizeParserType(configuredType)
}
// nvme attributes does not follow the same format as ata attributes,
// so we manually map each field to SmartAttributes

View File

@@ -24,7 +24,7 @@ func TestParseSmartForScsi(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData),
}
hasData, exitStatus := sm.parseSmartForScsi(data, "")
hasData, exitStatus := sm.parseSmartForScsi(data)
if !hasData {
t.Fatalf("expected SCSI data to parse successfully")
}
@@ -69,7 +69,7 @@ func TestParseSmartForSata(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData),
}
hasData, exitStatus := sm.parseSmartForSata(data, "")
hasData, exitStatus := sm.parseSmartForSata(data)
require.True(t, hasData)
assert.Equal(t, 64, exitStatus)
@@ -88,26 +88,6 @@ func TestParseSmartForSata(t *testing.T) {
}
}
func TestParseSmartForSataWithConfiguredType(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "sda.json")
data, err := os.ReadFile(fixturePath)
require.NoError(t, err)
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
}
hasData, exitStatus := sm.parseSmartForSata(data, "sat+megaraid")
require.True(t, hasData)
assert.Equal(t, 64, exitStatus)
deviceData, ok := sm.SmartDataMap["9C40918040082"]
require.True(t, ok, "expected smart data entry for serial 9C40918040082")
assert.Equal(t, "sat+megaraid", deviceData.DiskType,
"DiskType should be overridden by configuredType")
}
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
jsonPayload := []byte(`{
"smartctl": {"exit_status": 0},
@@ -132,7 +112,7 @@ func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
@@ -167,7 +147,7 @@ func TestParseSmartForSataAtaDeviceStatistics(t *testing.T) {
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
@@ -204,7 +184,7 @@ func TestParseSmartForSataNegativeDeviceStatistics(t *testing.T) {
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
@@ -243,7 +223,7 @@ func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
@@ -265,7 +245,7 @@ func TestParseSmartForNvme(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData),
}
hasData, exitStatus := sm.parseSmartForNvme(data, "")
hasData, exitStatus := sm.parseSmartForNvme(data)
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
@@ -1245,7 +1225,7 @@ func TestParseSmartForNvmeAppleSSD(t *testing.T) {
darwinNvmeProvider: fakeProvider,
}
hasData, _ := sm.parseSmartForNvme(data, "")
hasData, _ := sm.parseSmartForNvme(data)
require.True(t, hasData)
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
@@ -1257,7 +1237,7 @@ func TestParseSmartForNvmeAppleSSD(t *testing.T) {
assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
// Second parse: provider should NOT be called again (cache hit)
_, _ = sm.parseSmartForNvme(data, "")
_, _ = sm.parseSmartForNvme(data)
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
}

18
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/henrygd/beszel
go 1.26.3
go 1.26.1
require (
github.com/blang/semver v3.5.1+incompatible
@@ -10,7 +10,7 @@ require (
github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.9.1
github.com/nicholas-fedor/shoutrrr v0.15.1
github.com/nicholas-fedor/shoutrrr v0.14.3
github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.36.8
github.com/shirou/gopsutil/v4 v4.26.3
@@ -18,10 +18,9 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.52.0
golang.org/x/crypto v0.49.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/net v0.55.0
golang.org/x/sys v0.45.0
golang.org/x/sys v0.42.0
gopkg.in/yaml.v3 v3.0.1
howett.net/plist v1.0.1
)
@@ -47,7 +46,7 @@ require (
github.com/klauspost/compress v1.18.5 // indirect
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
@@ -56,11 +55,12 @@ require (
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

57
go.sum
View File

@@ -1,7 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
@@ -56,8 +56,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -81,16 +81,16 @@ github.com/lxzan/gws v1.9.1 h1:4lbIp4cW0hOLP3ejFHR/uWRy741AURx7oKkNNi2OT9o=
github.com/lxzan/gws v1.9.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.15.1 h1:dfgqpaeyr0CwUhqtwWBHS4girmAvFPOoxroHaVH1q1Y=
github.com/nicholas-fedor/shoutrrr v0.15.1/go.mod h1:xrdV1ab2W0/xa5kM6WP9mBuqVuJaDWqUwYvYvVuPTjk=
github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag=
github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA=
github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
github.com/nicholas-fedor/shoutrrr v0.14.3 h1:aBX2iw9a7jl5wfHd3bi9LnS5ucoYIy6KcLH9XVF+gig=
github.com/nicholas-fedor/shoutrrr v0.14.3/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -133,18 +133,18 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@@ -153,17 +153,18 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

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
}

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")
}
}

View File

@@ -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)
}

View File

@@ -1,24 +1,18 @@
# Specialized images available for Nvidia / Intel GPUs
#
# Docs: https://beszel.dev/guide/agent-installation
# Env vars: https://beszel.dev/guide/environment-variables
services:
beszel-agent:
image: henrygd/beszel-agent
container_name: beszel-agent
image: 'henrygd/beszel-agent' #Or henrygd/beszel-agent-nvidia
container_name: 'beszel-agent'
restart: unless-stopped
network_mode: host
# Only when using henrygd/beszel-agent-nvidia
# runtime: nvidia
volumes:
- ./beszel_agent_data:/var/lib/beszel-agent
- /var/run/docker.sock:/var/run/docker.sock:ro
# monitor other disks / partitions by mounting a folder in /extra-filesystems
# - /mnt/disk1/.beszel:/extra-filesystems/disk1:ro
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
environment:
LISTEN: 45876
KEY: "<public key>"
HUB_URL: "<hub url>"
TOKEN: "<token>"
# healthcheck:
# test: ['CMD', '/agent', 'health']
# interval: 120s
PORT: 45876
KEY: 'ssh-ed25519 YOUR_PUBLIC_KEY'
# Only when using henrygd/beszel-agent-nvidia
# NVIDIA_VISIBLE_DEVICES: all
# NVIDIA_DRIVER_CAPABILITIES: compute,video,utility

View File

@@ -1,17 +1,9 @@
# Docs: https://beszel.dev/guide/hub-installation
# Env vars: https://beszel.dev/guide/environment-variables
services:
beszel:
image: "henrygd/beszel"
container_name: "beszel"
image: 'henrygd/beszel'
container_name: 'beszel'
restart: unless-stopped
ports:
- "8090:8090"
- '8090:8090'
volumes:
- ./beszel_data:/beszel_data
# healthcheck:
# test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090']
# interval: 120s
# start_period: 10s
# timeout: 5s

View File

@@ -1,38 +1,26 @@
# Docs: https://beszel.dev/guide/getting-started
# Env vars: https://beszel.dev/guide/environment-variables
services:
beszel:
image: henrygd/beszel:latest
container_name: beszel
image: 'henrygd/beszel'
container_name: 'beszel'
restart: unless-stopped
environment:
APP_URL: http://localhost:8090
ports:
- 8090:8090
- '8090:8090'
volumes:
- ./beszel_data:/beszel_data
- ./beszel_socket:/beszel_socket
# healthcheck:
# test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090']
# interval: 120s
# start_period: 10s
# timeout: 5s
extra_hosts:
- 'host.docker.internal:host-gateway'
beszel-agent:
image: henrygd/beszel-agent:latest
container_name: beszel-agent
image: 'henrygd/beszel-agent' #Add -nvidia for nvidia gpus
container_name: 'beszel-agent'
restart: unless-stopped
network_mode: host
# runtime: nvidia # when using beszel-agent-nvidia
volumes:
- ./beszel_agent_data:/var/lib/beszel-agent
- ./beszel_socket:/beszel_socket
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
LISTEN: /beszel_socket/beszel.sock
HUB_URL: http://localhost:8090
TOKEN: <token>
KEY: "<key>"
# healthcheck:
# test: ['CMD', '/agent', 'health']
# interval: 120s
PORT: 45876
KEY: '...'
# FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats
# NVIDIA_VISIBLE_DEVICES: all # when using beszel-agent-nvidia
# NVIDIA_DRIVER_CAPABILITIES: utility # when using beszel-agent-nvidia