diff options
| author | Ophestra <cat@gensokyo.uk> | 2026-10-03 19:01:53 +0900 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2026-10-03 21:03:10 +0900 |
| commit | aa35067f0caeb709edd60dc76fc68ac33c3e1a25 (patch) | |
| tree | 928cbdb953a31514781b4e10b43b914275087a1c | |
| parent | f2e188c4a0f472bcbd87189f63099d2c73936789 (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.go | 350 | ||||
| -rw-r--r-- | test/internal/testsuite/proc_test.go | 33 | ||||
| -rw-r--r-- | test/internal/testsuite/testsuite.go | 8 | ||||
| -rw-r--r-- | test/internal/testsuite/testsuite_guard.go | 15 |
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) + } +} |
