diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 30a9a8a..d5e4017 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -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(), @@ -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 diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index 419ace5..fcbb386 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -2,6 +2,7 @@ package collector import ( "context" + "encoding/json" "testing" "time" @@ -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() diff --git a/internal/collector/disk.go b/internal/collector/disk.go index 6d779df..3b45c25 100644 --- a/internal/collector/disk.go +++ b/internal/collector/disk.go @@ -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] { diff --git a/internal/collector/disk_test.go b/internal/collector/disk_test.go index f656168..a337e3b 100644 --- a/internal/collector/disk_test.go +++ b/internal/collector/disk_test.go @@ -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) {