aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOphestra <cat@gensokyo.uk>2026-10-03 19:01:53 +0900
committerOphestra <cat@gensokyo.uk>2026-10-03 21:03:10 +0900
commitaa35067f0caeb709edd60dc76fc68ac33c3e1a25 (patch)
tree928cbdb953a31514781b4e10b43b914275087a1c
parentf2e188c4a0f472bcbd87189f63099d2c73936789 (diff)
test/internal/testsuite: simple proc scanner
This replaces procps in the replacement test suite and enables faster and much more precise matching. Signed-off-by: Ophestra <cat@gensokyo.uk>
-rw-r--r--test/internal/testsuite/proc.go350
-rw-r--r--test/internal/testsuite/proc_test.go33
-rw-r--r--test/internal/testsuite/testsuite.go8
-rw-r--r--test/internal/testsuite/testsuite_guard.go15
4 files changed, 406 insertions, 0 deletions
diff --git a/test/internal/testsuite/proc.go b/test/internal/testsuite/proc.go
new file mode 100644
index 00000000..f2ad8857
--- /dev/null
+++ b/test/internal/testsuite/proc.go
@@ -0,0 +1,350 @@
+package testsuite
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "unsafe"
+)
+
+// Stat represents status information read from /proc/pid/stat.
+type Stat struct {
+ // The process ID.
+ PID int
+ // The filename of the executable, with parenthesis stripped.
+ Comm string
+ // One of the following characters, indicating process state:
+ //
+ // R Running
+ //
+ // S Sleeping in an interruptible wait
+ //
+ // D Waiting in uninterruptible disk sleep
+ //
+ // Z Zombie
+ //
+ // T Stopped (on a signal) or (before Linux
+ // 2.6.33) trace stopped
+ //
+ // t Tracing stop (Linux 2.6.33 onward)
+ //
+ // W Paging (only before Linux 2.6.0)
+ //
+ // X Dead (from Linux 2.6.0 onward)
+ //
+ // x Dead (Linux 2.6.33 to 3.13 only)
+ //
+ // K Wakekill (Linux 2.6.33 to 3.13 only)
+ //
+ // W Waking (Linux 2.6.33 to 3.13 only)
+ //
+ // P Parked (Linux 3.9 to 3.13 only)
+ //
+ // I Idle (Linux 4.14 onward)
+ State byte
+ // The process ID of the parent of this process.
+ PPID int
+ // The process group ID of the process.
+ PGRP int
+ // The session ID of the process.
+ Session int
+ // The controlling terminal of the process.
+ TTYNR int
+ // The ID of the foreground process group of the controlling terminal of the
+ // process.
+ TPGID int
+ // The kernel flags word of the process. For bit meanings, see the PF_*
+ // defines in the Linux kernel source file include/linux/sched.h.
+ Flags uint
+ // The number of minor faults the process has made which have not required
+ // loading a memory page from disk.
+ MinFlt uint
+ // The number of minor faults that the process's waited-for children have
+ // made.
+ CMinFlt uint
+ // The number of major faults the process has made which have required
+ // loading a memory page from disk.
+ MajFlt uint
+ // The number of major faults that the process's waited-for children have
+ // made.
+ CMajFlt uint
+ // Amount of time that this process has been scheduled in user mode,
+ // measured in clock ticks.
+ UTime uint
+ // Amount of time that this process has been scheduled in kernel mode,
+ // measured in clock ticks.
+ STime uint
+ // Amount of time that this process's waited-for children have been
+ // scheduled in user mode, measured in clock ticks.
+ CUTime int
+ // Amount of time that this process's waited-for children have been
+ // scheduled in kernel mode, measured in clock ticks.
+ CSTime int
+ // For processes running a real-time scheduling policy, this is the negated
+ // scheduling priority, minus one.
+ Priority int
+ // The nice value, a value in the range 19 (low priority) to -20 (high
+ // priority).
+ Nice int
+ // Number of threads in this process.
+ NumThreads int
+
+ // unmaintained field: itrealvalue
+
+ // The time the process started after system boot. Since Linux 2.6, the
+ // value is expressed in clock ticks.
+ StartTime uint64
+ // Virtual memory size in bytes.
+ VSize uint
+ // Resident set size in pages.
+ RSS int
+ // Soft limit in bytes on the rss of the process.
+ RSSLim uint64
+ // The address above which program text can run.
+ StartCode uint64
+ // The address below which program text can run.
+ EndCode uint64
+ // The address of the start (i.e., bottom) of the stack.
+ StartStack uint64
+ // The current value of ESP (stack pointer), as found in the kernel stack
+ // page for the process.
+ KSTKESP uint64
+ // The current EIP (instruction pointer).
+ KSTKEIP uint64
+
+ // obsolete fields: signal, blocked, sigignore, sigcatch
+
+ // This is the "channel" in which the process is waiting. It is the address
+ // of a location in the kernel where the process is sleeping.
+ WChan uint64
+
+ // unmaintained fields: nswap, cnswap
+
+ // Signal to be sent to parent when we die.
+ ExitSignal int
+ // CPU number last executed on.
+ Processor int
+ // Real-time scheduling priority, a number in the range 1 to 99 for processes
+ // scheduled under a real-time policy, or 0, for non-real-time processes.
+ RTPriority uint
+ // Scheduling policy (see sched_setscheduler(2)). Decode using the SCHED_*
+ // constants in linux/sched.h.
+ Policy uint
+ // Aggregated block I/O delays, measured in clock ticks (centiseconds).
+ DelayAcctBlkIOTicks uint64
+ // Guest time of the process (time spent running a virtual CPU for a guest
+ // operating system), measured in clock ticks.
+ GuestTime int
+ // Guest time of the process's children, measured in clock ticks.
+ CGuestTime int
+}
+
+// fhsProc points to a virtual kernel file system exposing the process list and
+// other functionality.
+const fhsProc = "/proc/"
+
+// Executable is like [os.Executable], but for the process referred to by s.
+func (s *Stat) Executable() (string, error) {
+ path, err := os.Readlink(filepath.Join(fhsProc, strconv.Itoa(s.PID), "exe"))
+
+ // When the executable has been deleted then Readlink returns a
+ // path appended with " (deleted)".
+ return strings.TrimSuffix(path, " (deleted)"), err
+}
+
+// Stat populates stat with the proc filesystem entry referred to by s.
+func (s *Stat) Stat(stat *syscall.Stat_t) (err error) {
+ err = syscall.Stat(filepath.Join(fhsProc, strconv.Itoa(s.PID)), stat)
+ if err != nil {
+ err = os.NewSyscallError("stat", err)
+ }
+ return
+}
+
+// Args reads arguments of the process referred to by s.
+func (s *Stat) Args() ([]string, error) {
+ p, err := os.ReadFile(filepath.Join(fhsProc, strconv.Itoa(s.PID), "cmdline"))
+ if err != nil {
+ return nil, err
+ }
+ a := bytes.Split(p, []byte{0})
+ if len(a) > 0 && len(a[len(a)-1]) == 0 {
+ a = a[:len(a)-1]
+ }
+
+ args := make([]string, len(a))
+ for i, arg := range a {
+ args[i] = unsafe.String(unsafe.SliceData(arg), len(arg))
+ }
+ return args, nil
+}
+
+// ErrBadDelimiters is returned by [Stat.UnmarshalText] if one or both bytes of
+// the comm delimiter pair were missing or misplaced.
+var ErrBadDelimiters = errors.New("missing comm delimiters")
+
+// UnmarshalText populates the structure pointed to by s from text.
+func (s *Stat) UnmarshalText(text []byte) (err error) {
+ var (
+ discard uint64
+ _uint64 = &discard
+ _int64 = (*int64)(unsafe.Pointer(&discard))
+
+ ld = bytes.Index(text, []byte("("))
+ rd = bytes.LastIndex(text, []byte(")"))
+ )
+
+ if ld <= 0 || rd < 0 {
+ return ErrBadDelimiters
+ }
+
+ if s.PID, err = strconv.Atoi(
+ unsafe.String(unsafe.SliceData(text), ld-1),
+ ); err != nil {
+ return
+ }
+
+ s.Comm = string(text[ld+1 : rd])
+
+ var (
+ n int
+
+ state string
+ )
+ n, err = fmt.Fscan(
+ bytes.NewBuffer(text[rd+2:]),
+ &state,
+ &s.PPID,
+ &s.PGRP,
+ &s.Session,
+ &s.TTYNR,
+ &s.TPGID,
+ &s.Flags,
+ &s.MinFlt,
+ &s.CMinFlt,
+ &s.MajFlt,
+ &s.CMajFlt,
+ &s.UTime,
+ &s.STime,
+ &s.CUTime,
+ &s.CSTime,
+ &s.Priority,
+ &s.Nice,
+ &s.NumThreads,
+ _int64,
+ &s.StartTime,
+ &s.VSize,
+ &s.RSS,
+ &s.RSSLim,
+ &s.StartCode,
+ &s.EndCode,
+ &s.StartStack,
+ &s.KSTKESP,
+ &s.KSTKEIP,
+ _uint64,
+ _uint64,
+ _uint64,
+ _uint64,
+ &s.WChan,
+ _uint64,
+ _uint64,
+ &s.ExitSignal,
+ &s.Processor,
+ &s.RTPriority,
+ &s.Policy,
+ &s.DelayAcctBlkIOTicks,
+ &s.GuestTime,
+ &s.CGuestTime,
+ )
+ if err != nil {
+ err = fmt.Errorf("field %d: %w", n, err)
+ } else if len(state) != 1 {
+ err = fmt.Errorf("invalid state %q", state)
+ } else {
+ s.State = state[0]
+ }
+ return
+}
+
+// A StatScanner continuously scans the proc filesystem for process status
+// information in /proc/pid/stat.
+type StatScanner struct {
+ // Current entry.
+ stat Stat
+ // Cached top-level /proc entries.
+ dents []os.DirEntry
+ // Current progress through dents.
+ i int
+ // Whether the previous call to Scan had repopulated dents.
+ wrapped bool
+ // First stored error: a non-nil err disables the scanner.
+ err error
+}
+
+// Scan reads a process status information entry. It returns false if an
+// unrecoverable error is encountered, after which Scan no longer scans new
+// entries.
+func (s *StatScanner) Scan() bool {
+ if s.err != nil {
+ return false
+ }
+
+ if s.wrapped = s.i == len(s.dents); s.wrapped {
+ if s.dents, s.err = os.ReadDir(fhsProc); s.err != nil {
+ return false
+ }
+ s.i = 0
+ if len(s.dents) == 0 {
+ s.err = syscall.ENOTRECOVERABLE
+ return false
+ }
+ }
+
+ for s.i < len(s.dents) {
+ dent := s.dents[s.i]
+ s.i++
+ if !dent.IsDir() {
+ continue
+ }
+
+ pid, err := strconv.Atoi(dent.Name())
+ if err != nil {
+ continue
+ }
+
+ var p []byte
+ p, err = os.ReadFile(filepath.Join(fhsProc, dent.Name(), "stat"))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ESRCH) {
+ continue
+ }
+ s.err = err
+ return false
+ }
+
+ s.err = s.stat.UnmarshalText(p)
+ if s.err == nil && pid != s.stat.PID {
+ s.err = fmt.Errorf(
+ "bad status information: dent=%d, stat=%d",
+ pid, s.stat.PID,
+ )
+ }
+ return s.err == nil
+ }
+ return s.Scan()
+}
+
+// Stat returns the address of the [Stat] structure populated by the last call
+// to Scan.
+func (s *StatScanner) Stat() *Stat { return &s.stat }
+
+// Err returns the stored error value.
+func (s *StatScanner) Err() error { return s.err }
+
+// Repopulated returns whether the last Scan call had re-read the proc filesystem.
+func (s *StatScanner) Repopulated() bool { return s.wrapped }
diff --git a/test/internal/testsuite/proc_test.go b/test/internal/testsuite/proc_test.go
new file mode 100644
index 00000000..a8698e73
--- /dev/null
+++ b/test/internal/testsuite/proc_test.go
@@ -0,0 +1,33 @@
+package testsuite_test
+
+import (
+ "testing"
+
+ "hakurei.app/test/internal/testsuite"
+)
+
+func BenchmarkStatScanner(b *testing.B) {
+ var s testsuite.StatScanner
+
+ for b.Loop() {
+ if !s.Scan() {
+ b.Fatal(s.Err())
+ }
+ }
+}
+
+func BenchmarkStatScannerFull(b *testing.B) {
+ var s testsuite.StatScanner
+
+ for b.Loop() {
+ for s.Scan() {
+ if s.Repopulated() {
+ break
+ }
+ }
+
+ if err := s.Err(); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
diff --git a/test/internal/testsuite/testsuite.go b/test/internal/testsuite/testsuite.go
index 2173509d..ef250ec8 100644
--- a/test/internal/testsuite/testsuite.go
+++ b/test/internal/testsuite/testsuite.go
@@ -1,4 +1,7 @@
// Package testsuite provides many quick-and-dirty integration testing utilities.
+//
+// Attempting to import this package outside testing causes the resulting
+// program to panic.
package testsuite
import (
@@ -8,9 +11,14 @@ import (
"os/signal"
"os/user"
"syscall"
+ "testing"
)
func init() {
+ if testing.Testing() {
+ return
+ }
+
log.SetFlags(0)
log.SetPrefix("testsuite: ")
}
diff --git a/test/internal/testsuite/testsuite_guard.go b/test/internal/testsuite/testsuite_guard.go
new file mode 100644
index 00000000..0859d17f
--- /dev/null
+++ b/test/internal/testsuite/testsuite_guard.go
@@ -0,0 +1,15 @@
+//go:build !testsuite && !tester
+
+package testsuite
+
+import (
+ "os"
+ "testing"
+)
+
+func init() {
+ if !testing.Testing() {
+ println("package testsuite imported in non-testsuite program")
+ os.Exit(1)
+ }
+}