Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion internal/collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ func New(cfg Config, log *slog.Logger) *Collector {
notifier = cfg.Notifier
}
c := &Collector{
cfg: cfg,
cfg: cfg,
// Disks starts as [] rather than nil so it marshals as [] (not
// null) before the first fast tick completes, matching docs/API.md.
latest: Snapshot{Disks: []Disk{}},
alerts: alerts,
notifier: notifier,
cpu: NewCPUCollector(),
Expand Down Expand Up @@ -365,6 +368,11 @@ func (c *Collector) fastTick(ctx context.Context) {
c.latest.Throttled = s.throttled
c.latest.Memory = s.mem
c.latest.Swap = s.swap
if s.disks == nil {
// A failed collection (e.g. /proc/mounts unreadable) leaves s.disks
// nil; keep Snapshot.Disks marshaling as [] rather than null.
s.disks = []Disk{}
}
c.latest.Disks = s.disks
c.latest.Network = s.netIfaces

Expand Down
57 changes: 57 additions & 0 deletions internal/collector/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package collector

import (
"context"
"encoding/json"
"testing"
"time"

Expand Down Expand Up @@ -46,6 +47,62 @@ func TestCollector_FastTick_PopulatesSnapshot(t *testing.T) {
}
}

// TestCollector_Disks_NeverMarshalsAsNull covers issue #70: docs/API.md
// documents disks as reading as an empty array before the first fast tick,
// but a nil []Disk marshals to JSON null, not []. Snapshot.Disks must stay
// a non-nil (possibly empty) slice both before the first tick and after.
func TestCollector_Disks_NeverMarshalsAsNull(t *testing.T) {
c := newTestCollector()

assertDisksMarshalsAsEmptyArray := func(t *testing.T, snap Snapshot) {
t.Helper()
if snap.Disks == nil {
t.Fatal("expected Snapshot.Disks to be non-nil")
}
b, err := json.Marshal(snap)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
if got := string(raw["disks"]); got == "null" {
t.Fatalf(`expected "disks" to marshal as [], got %s`, got)
}
}

// Before the first fast tick completes.
assertDisksMarshalsAsEmptyArray(t, c.Snapshot())

// After a tick.
c.fastTick(context.Background())
assertDisksMarshalsAsEmptyArray(t, c.Snapshot())
}

// TestCollector_FastTick_DiskCollectionErrorYieldsEmptyNotNilDisks covers
// the failed-collection path directly: DiskCollector.Collect returns
// (nil, err) when /proc/mounts can't be read, and fastTick must still
// normalize that nil into a non-nil empty slice rather than storing it
// verbatim.
func TestCollector_FastTick_DiskCollectionErrorYieldsEmptyNotNilDisks(t *testing.T) {
c := newTestCollector()
c.disk = &DiskCollector{
mountsPath: "/nonexistent/proc/mounts",
excludedFSType: defaultExcludedFSTypes,
}

c.fastTick(context.Background())

snap := c.Snapshot()
if snap.Disks == nil {
t.Fatal("expected Snapshot.Disks to be non-nil even when disk collection fails")
}
if len(snap.Disks) != 0 {
t.Fatalf("expected 0 disks after a failed collection, got %d", len(snap.Disks))
}
}

func TestCollector_FastTick_BuildsHistory(t *testing.T) {
c := newTestCollector()
ctx := context.Background()
Expand Down
4 changes: 3 additions & 1 deletion internal/collector/disk.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ func (c *DiskCollector) Collect() ([]Disk, error) {
order, byMountpoint := dedupeMounts(entries)

now := c.clock()
var disks []Disk
// Always non-nil (even with zero entries) so Snapshot.Disks marshals as
// [] rather than null, matching docs/API.md.
disks := make([]Disk, 0, len(order))
for _, mp := range order {
e := byMountpoint[mp]
if c.excludedFSType[e.fstype] {
Expand Down
30 changes: 30 additions & 0 deletions internal/collector/disk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,36 @@ func TestDiskCollector_Collect_SkipsFailingStatfs(t *testing.T) {
if len(disks) != 0 {
t.Fatalf("expected 0 disks when all statfs calls fail, got %d", len(disks))
}
if disks == nil {
t.Fatal("expected non-nil empty slice (marshals as [], not null), got nil")
}
}

func TestDiskCollector_Collect_NoMountsReturnsEmptyNotNilSlice(t *testing.T) {
// Only pseudo filesystems, all excluded by default: order/byMountpoint
// end up empty, but Collect must still return a non-nil slice so
// Snapshot.Disks marshals as [] rather than null (issue #70).
fixture := "proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0\n"
mountsPath := writeMountsFixture(t, fixture)

c := &DiskCollector{
mountsPath: mountsPath,
excludedFSType: defaultExcludedFSTypes,
statfs: func(path string, buf *syscall.Statfs_t) error {
return os.ErrNotExist
},
}

disks, err := c.Collect()
if err != nil {
t.Fatalf("Collect: %v", err)
}
if disks == nil {
t.Fatal("expected non-nil empty slice when no mountpoints qualify, got nil")
}
if len(disks) != 0 {
t.Fatalf("expected 0 disks, got %d", len(disks))
}
}

func TestDiskCollector_Collect_HungStatfsTimesOutAndCoolsDown(t *testing.T) {
Expand Down
Loading