package hostcollect import ( "context" "encoding/json" "errors" "os" "path/filepath" "strings" "testing" "time" "github.com/itworx/pulse/internal/process" ) func processByPID(t *testing.T, snapshot process.RawSnapshot, pid int) process.RawProcess { t.Helper() for _, item := range snapshot.Processes { if item.PID == pid { return item } } t.Fatalf("pid %d not found in %+v", pid, snapshot.Processes) return process.RawProcess{} } func TestParseProcessStatHandlesParenthesesInTheProgramName(t *testing.T) { stat, err := parseProcessStat(readFixture(t, "proc-healthy", "1234", "stat"), 1234) if err != nil { t.Fatalf("parseProcessStat returned error: %v", err) } if stat.comm != "my app) (2)" { t.Fatalf("comm = %q; the parser must cut at the last closing parenthesis", stat.comm) } if stat.ticks != 100000+20000 { t.Fatalf("ticks = %d", stat.ticks) } if stat.startTime != 5000 || stat.rssPages != 250000 { t.Fatalf("unexpected stat: %+v", stat) } if stat.state != "sleeping" { t.Fatalf("state = %q", stat.state) } } func TestParseProcessStatRejectsMalformedInput(t *testing.T) { cases := map[string][]byte{ "truncated": readFixture(t, "proc-healthy", "9999", "stat"), "no comm": []byte("1 systemd S 0 1\n"), "empty": {}, "bad number": []byte("1 (x) S 0 0 0 0 0 0 0 0 0 0 nan nan 0 0 0 0 0 0 0 0 0 0\n"), } for name, data := range cases { t.Run(name, func(t *testing.T) { if _, err := parseProcessStat(data, 1); err == nil { t.Fatal("expected an error") } }) } } func TestProcessesCollectsInventoryAndSkipsUnreadableTasks(t *testing.T) { collector, clock := newTestCollector(t, Options{PageSize: 4096}) snapshot, err := collector.Processes(context.Background()) if err != nil { t.Fatalf("Processes returned error: %v", err) } if len(snapshot.Processes) != 5 { t.Fatalf("processes = %d, want 5 (1, 2, 1234, 4567, 5555): %+v", len(snapshot.Processes), snapshot.Processes) } // 9999 has a truncated stat file; 3131 exists in the directory listing and even has // a cmdline, but its stat is already gone — the task exited between readdir and // open, which must cost one row and not the whole collection. for _, pid := range []int{9999, 3131} { for _, item := range snapshot.Processes { if item.PID == pid { t.Fatalf("pid %d must be skipped: malformed stat or a task that vanished mid-scan", pid) } } } init := processByPID(t, snapshot, 1) if init.Name != "init" { t.Fatalf("name = %q, want the command line's program name", init.Name) } if init.MemoryBytes != 12844*1024 { t.Fatalf("memory = %d, want VmRSS from status", init.MemoryBytes) } if init.RuntimeSeconds < 351281 || init.RuntimeSeconds > 351283 { t.Fatalf("runtime = %v", init.RuntimeSeconds) } kernelThread := processByPID(t, snapshot, 2) if kernelThread.Name != "kthreadd" { t.Fatalf("a kernel thread with an empty cmdline must fall back to comm, got %q", kernelThread.Name) } if kernelThread.MemoryBytes != 0 { t.Fatalf("memory = %d, want 0 for a kernel thread", kernelThread.MemoryBytes) } // 5555 has a stat file but no status or cmdline: it exited between the two reads. vanished := processByPID(t, snapshot, 5555) if vanished.Name != "short-lived" || vanished.MemoryBytes != 500*4096 { t.Fatalf("unexpected fallback for a task that vanished between reads: %+v", vanished) } if vanished.State != "zombie" { t.Fatalf("state = %q", vanished.State) } if _, err := process.Normalize(snapshot, clock.now(), process.Limits{}); err != nil { t.Fatalf("process.Normalize rejected the collected snapshot: %v", err) } } func TestProcessesNeverCopyCommandLineArguments(t *testing.T) { collector, _ := newTestCollector(t, Options{}) snapshot, err := collector.Processes(context.Background()) if err != nil { t.Fatalf("Processes returned error: %v", err) } payload, err := json.Marshal(snapshot) if err != nil { t.Fatalf("marshal: %v", err) } for _, secret := range []string{"SUPER-SECRET-VALUE", "hunter2", "--token", "--password", "virtio-net-pci"} { if strings.Contains(string(payload), secret) { t.Fatalf("snapshot leaked command line argument %q", secret) } } weird := processByPID(t, snapshot, 1234) if weird.Name != "weird app (2)" { t.Fatalf("name = %q, want only the program name", weird.Name) } // The 29 KiB command line of pid 4567 is read bounded and reduced to its base name. huge := processByPID(t, snapshot, 4567) if huge.Name != "qemu-system-x86_64" { t.Fatalf("name = %q", huge.Name) } if len(huge.Name) > maxNameBytes { t.Fatalf("name length = %d", len(huge.Name)) } } func TestProcessesEnforceTheRowLimitByCost(t *testing.T) { collector, _ := newTestCollector(t, Options{ProcessLimits: process.Limits{MaxRows: 2, MaxPageSize: 10}}) snapshot, err := collector.Processes(context.Background()) if err != nil { t.Fatalf("Processes returned error: %v", err) } if len(snapshot.Processes) != 2 { t.Fatalf("processes = %d, want the limit of 2", len(snapshot.Processes)) } // Without a previous sample every CPU percentage is zero, so the tie breaks on // resident pages: the two largest tasks survive. if snapshot.Processes[0].PID != 1234 || snapshot.Processes[1].PID != 4567 { t.Fatalf("unexpected survivors: %+v", snapshot.Processes) } } func TestProcessCPUPercentIsADeltaAndSurvivesPIDReuse(t *testing.T) { root := copyTree(t, filepath.Join("testdata", "proc-healthy")) collector, clock := newTestCollector(t, Options{ProcRoot: root}) first, err := collector.Processes(context.Background()) if err != nil { t.Fatalf("first scan: %v", err) } if processByPID(t, first, 1234).CPUPercent != 0 { t.Fatal("a single sample cannot yield CPU utilisation") } // 500 extra ticks over 10 seconds at 100 USER_HZ is exactly 50% of one core. writeFile(t, filepath.Join(root, "1234", "stat"), "1234 (my app) (2)) S 1 1234 1234 0 -1 4194304 55555 0 12 0 100400 20100 0 0 20 0 8 0 5000 987654321 250000 0 1 1 0 0 0 0 0 0 0 0 0 0 17 5 0 0 0 0 0\n") // pid 1 is replaced by a new task that reuses the PID: a different start time. writeFile(t, filepath.Join(root, "1", "stat"), "1 (systemd) S 0 1 1 0 -1 4194560 12345 678910 12 34 90456 789 1000 2000 20 0 1 0 99 172032000 3210 0 1 1 0 0 0 0 0 0 0 0 0 0 17 3 0 0 0 0 0\n") clock.advance(10 * time.Second) second, err := collector.Processes(context.Background()) if err != nil { t.Fatalf("second scan: %v", err) } if got := processByPID(t, second, 1234).CPUPercent; got < 49.9 || got > 50.1 { t.Fatalf("cpu = %v, want 50", got) } if got := processByPID(t, second, 1).CPUPercent; got != 0 { t.Fatalf("cpu = %v; a reused PID must not inherit the previous task's ticks", got) } if _, err := process.Normalize(second, clock.now(), process.Limits{}); err != nil { t.Fatalf("process.Normalize rejected the snapshot: %v", err) } } func TestProcessCPUPercentIgnoresBackwardsCounters(t *testing.T) { root := copyTree(t, filepath.Join("testdata", "proc-healthy")) collector, clock := newTestCollector(t, Options{ProcRoot: root}) if _, err := collector.Processes(context.Background()); err != nil { t.Fatalf("first scan: %v", err) } writeFile(t, filepath.Join(root, "1234", "stat"), "1234 (my app) (2)) S 1 1234 1234 0 -1 4194304 55555 0 12 0 1 1 0 0 20 0 8 0 5000 987654321 250000 0 1 1 0 0 0 0 0 0 0 0 0 0 17 5 0 0 0 0 0\n") clock.advance(10 * time.Second) second, err := collector.Processes(context.Background()) if err != nil { t.Fatalf("second scan: %v", err) } if got := processByPID(t, second, 1234).CPUPercent; got != 0 { t.Fatalf("cpu = %v, want 0 for a counter that went backwards", got) } } func TestProcessesRetainedStateStaysBounded(t *testing.T) { root := copyTree(t, filepath.Join("testdata", "proc-healthy")) collector, clock := newTestCollector(t, Options{ProcRoot: root}) if _, err := collector.Processes(context.Background()); err != nil { t.Fatalf("first scan: %v", err) } if err := os.RemoveAll(filepath.Join(root, "1234")); err != nil { t.Fatalf("remove: %v", err) } clock.advance(10 * time.Second) if _, err := collector.Processes(context.Background()); err != nil { t.Fatalf("second scan: %v", err) } collector.mu.Lock() retained := len(collector.processes) collector.mu.Unlock() if retained != 4 { t.Fatalf("retained samples = %d, want only the live tasks", retained) } } func TestProcessesFailWhenTheProcfsRootIsUnusable(t *testing.T) { collector, _ := newTestCollector(t, Options{ProcRoot: filepath.Join("testdata", "does-not-exist")}) if _, err := collector.Processes(context.Background()); err == nil { t.Fatal("expected an error when the procfs root cannot be read") } } func TestProcessesHonourContextCancellation(t *testing.T) { collector, _ := newTestCollector(t, Options{}) ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := collector.Processes(ctx); !errors.Is(err, context.Canceled) { t.Fatalf("error = %v, want context.Canceled", err) } } func TestSanitizeNameDropsControlCharacters(t *testing.T) { if got := sanitizeName("bad\x00name\x1b[31m"); got != "badname[31m" { t.Fatalf("sanitizeName = %q", got) } if got := commandName([]byte("/usr/bin/env\x00FOO=bar\x00")); got != "env" { t.Fatalf("commandName = %q", got) } if got := commandName(nil); got != "" { t.Fatalf("commandName = %q", got) } }