diff --git a/engine/UPSTREAM.md b/engine/UPSTREAM.md index f55ad000..a56ffbd2 100644 --- a/engine/UPSTREAM.md +++ b/engine/UPSTREAM.md @@ -31,7 +31,10 @@ **9,621**(对照 `f4c50506`)、**9,659**(对照 `be48afbe`),生产 3,852、 测试 5,758。 -按包(对照 `26dbcae`,后续分支): +2026-11-12 的 ports 重写在此基础上继续下降:对照 `26dbcae` 为 **8,933** 行 / 664 段, +分布在 135 个文件(生产 3,564、测试 5,369)。 + +按包(对照 `26dbcae`,ports 重写后): | 包 | 生产 | 测试 | 合计 | | --- | ---: | ---: | ---: | @@ -39,12 +42,12 @@ | `internal/cmd` | 837 | 703 | 1,540 | | `internal/groups` | 336 | 506 | 842 | | `internal/scanner` | 220 | 540 | 760 | -| `internal/ports` | 288 | 389 | 677 | +| `internal/ports` | 0 | 0 | 0 | | `internal/killer` | 86 | 158 | 244 | | `internal/store` | 12 | 5 | 17 | -`internal/claims`、`internal/display`、`internal/testenv` 与 `internal/store/claims*.go` -已经归零;`internal/docker` 与 `internal/ports/displayname.go` 只剩结构体字段与表驱动 +`internal/claims`、`internal/display`、`internal/testenv`、`internal/ports` 与 +`internal/store/claims*.go` 已经归零;`internal/docker` 只剩结构体字段与表驱动 用例数据。`internal/store` 已整包重写:生产代码从 1,076 行降到 199 行。 ```sh diff --git a/engine/internal/ports/cwd_peb.go b/engine/internal/ports/cwd_peb.go index 38e3c35f..1dae5607 100644 --- a/engine/internal/ports/cwd_peb.go +++ b/engine/internal/ports/cwd_peb.go @@ -1,154 +1,100 @@ package ports import ( + "encoding/binary" "strings" "unicode/utf16" "unsafe" ) -// Windows keeps a process's current directory in its own address space, in the -// PEB, not in any table the kernel will hand out. Reading it is therefore -// pointer arithmetic over three hops of foreign memory: +// A Windows process keeps its current directory in its own address space, not +// in a table the kernel hands out. Reading it means following three pointers +// through foreign memory: // -// PEB.ProcessParameters -> RTL_USER_PROCESS_PARAMETERS -// RTL_USER_PROCESS_PARAMETERS.CurrentDirectory.DosPath -> UNICODE_STRING -// UNICODE_STRING.Buffer -> the UTF-16 path itself +// PEB.ProcessParameters -> RTL_USER_PROCESS_PARAMETERS +// ...CurrentDirectory.DosPath -> UNICODE_STRING +// UNICODE_STRING.Buffer -> the UTF-16 path // -// None of that arithmetic needs Windows to be exercised, so the offsets and the -// decoding live here, in a file that builds everywhere and is unit-tested on -// the developer's machine. cwd_windows.go supplies the three reads. - -// pebLayout is the byte geometry of those structures for one pointer size. -// Every field is an offset in bytes from the start of its structure. -type pebLayout struct { - // ptrSize is the width of a pointer in the target process: 8 for a 64-bit - // process read from a 64-bit option-berth, 4 for a 32-bit process read from a - // 32-bit option-berth. - ptrSize int - // processParameters is PEB.ProcessParameters. - processParameters uintptr - // currentDirectory is RTL_USER_PROCESS_PARAMETERS.CurrentDirectory.DosPath, - // i.e. the UNICODE_STRING at the head of the CURDIR. - currentDirectory uintptr - // unicodeBuffer is UNICODE_STRING.Buffer; unicodeSize is sizeof(UNICODE_STRING). - unicodeBuffer uintptr - unicodeSize int -} - -// layout64 is the geometry for 64-bit targets, spelled out so a test can pin it -// without trusting the same arithmetic it is checking. These are the numbers -// every debugger prints for ntdll's structures on amd64/arm64. -var layout64 = pebLayout{ - ptrSize: 8, - processParameters: 0x20, - currentDirectory: 0x38, - unicodeBuffer: 0x08, - unicodeSize: 0x10, +// Only the offsets depend on the target ABI. Everything else works on plain +// byte slices, so this file builds and is tested on every platform; +// cwd_windows.go supplies the remote reads. + +// winDirGeometry is where one target ABI keeps those three structures. +// Offsets are bytes from the start of the structure named by the field. +type winDirGeometry struct { + wordsize int // pointer width in the target process + params uintptr // PEB.ProcessParameters + currentDir uintptr // RTL_USER_PROCESS_PARAMETERS.CurrentDirectory.DosPath } -// layout32 is the same geometry for 32-bit targets. -var layout32 = pebLayout{ - ptrSize: 4, - processParameters: 0x10, - currentDirectory: 0x24, - unicodeBuffer: 0x04, - unicodeSize: 0x08, -} - -// The mirror structs below exist only so the layout can be *derived* rather -// than asserted: Go lays them out with the same natural alignment the C -// declarations get, so unsafe.Offsetof reproduces ntdll's offsets for whatever -// word size this binary was built for. They are never allocated. - -type mirrorUnicodeString struct { - Length uint16 - MaximumLength uint16 - Buffer uintptr -} - -type mirrorCurDir struct { - DosPath mirrorUnicodeString - Handle uintptr -} - -type mirrorProcessParameters struct { - MaximumLength uint32 - Length uint32 - Flags uint32 - DebugFlags uint32 - ConsoleHandle uintptr - ConsoleFlags uint32 - StandardInput uintptr - StandardOutput uintptr - StandardError uintptr - CurrentDirectory mirrorCurDir -} - -type mirrorPEB struct { - InheritedAddressSpace byte - ReadImageFileExecOptions byte - BeingDebugged byte - BitField byte - Mutant uintptr - ImageBaseAddress uintptr - Ldr uintptr - ProcessParameters uintptr -} +// The two Windows ABIs. A 32-bit target has the same structures with +// half-width pointers; the target's word size, not this binary's, picks the +// numbers, so they cannot both be derived from one compiled layout. +var ( + winDir64 = winDirGeometry{wordsize: 8, params: 0x20, currentDir: 0x38} + winDir32 = winDirGeometry{wordsize: 4, params: 0x10, currentDir: 0x24} +) -// nativeLayout is the geometry of a process with this binary's own word size. -// A 64-bit option-berth reads 64-bit PEBs — including those of WOW64 processes, which -// keep a populated 64-bit PEB alongside their 32-bit one. -func nativeLayout() pebLayout { - return pebLayout{ - ptrSize: int(unsafe.Sizeof(uintptr(0))), - processParameters: unsafe.Offsetof(mirrorPEB{}.ProcessParameters), - currentDirectory: unsafe.Offsetof(mirrorProcessParameters{}.CurrentDirectory), - unicodeBuffer: unsafe.Offsetof(mirrorUnicodeString{}.Buffer), - unicodeSize: int(unsafe.Sizeof(mirrorUnicodeString{})), +// bufferOffset is UNICODE_STRING.Buffer: two 16-bit lengths and then the +// pointer, aligned to the target's word size. +func (g winDirGeometry) bufferOffset() uintptr { return uintptr(g.wordsize) } + +// headerSize is sizeof(UNICODE_STRING) in the target: the two lengths are +// padded out to the pointer width, which halves the structure on a 32-bit +// target. +func (g winDirGeometry) headerSize() int { return 2 * g.wordsize } + +// selfGeometry is the ABI this binary reads natively. A 64-bit build reads +// 64-bit PEBs, including those of WOW64 processes, which keep a populated +// 64-bit PEB next to their 32-bit one. +func selfGeometry() winDirGeometry { + if unsafe.Sizeof(uintptr(0)) == 8 { + return winDir64 } + return winDir32 } -// pointerAt decodes the little-endian pointer stored at off. ok is false when -// buf is too short, which is how a short or refused ReadProcessMemory surfaces. -func (l pebLayout) pointerAt(buf []byte, off uintptr) (addr uint64, ok bool) { - end := off + uintptr(l.ptrSize) - if uintptr(len(buf)) < end { +// uintptrValue decodes one little-endian pointer of the given width. A short +// buffer is how a refused or truncated remote read arrives here: half a +// pointer would otherwise decode to a plausible-looking wrong address. +func uintptrValue(b []byte, at uintptr, wordsize int) (uint64, bool) { + if at > uintptr(len(b)) || uintptr(len(b))-at < uintptr(wordsize) { return 0, false } - for i := l.ptrSize - 1; i >= 0; i-- { - addr = addr<<8 | uint64(buf[off+uintptr(i)]) + switch wordsize { + case 8: + return binary.LittleEndian.Uint64(b[at:]), true + case 4: + return uint64(binary.LittleEndian.Uint32(b[at:])), true + default: + return 0, false } - return addr, true } -// unicodeStringAt decodes the UNICODE_STRING that starts at off: its Length (in -// bytes, not characters — the field is famously not a count of runes) and the -// address of its buffer. ok is false for a truncated read, an odd Length, an -// empty Length or a null buffer. Length needs no upper bound of its own: it is -// a uint16, so the worst a wrong offset can ask us to allocate is 64 KiB. -func (l pebLayout) unicodeStringAt(buf []byte, off uintptr) (length int, addr uint64, ok bool) { - if uintptr(len(buf)) < off+uintptr(l.unicodeSize) { - return 0, 0, false - } - length = int(buf[off]) | int(buf[off+1])<<8 - addr, ok = l.pointerAt(buf, off+l.unicodeBuffer) - if !ok || addr == 0 { +// dosPath decodes the UNICODE_STRING header at the start of b: how many bytes +// the path occupies and where its buffer starts. Length counts bytes, not +// characters, so an odd value is not UTF-16; a zero length or a null buffer +// means the target has no directory to report. +func (g winDirGeometry) dosPath(b []byte) (size int, data uint64, ok bool) { + if len(b) < g.headerSize() { return 0, 0, false } - if length <= 0 || length%2 != 0 { + n := int(binary.LittleEndian.Uint16(b[:2])) + addr, ok := uintptrValue(b, g.bufferOffset(), g.wordsize) + if !ok || addr == 0 || n <= 0 || n%2 != 0 { return 0, 0, false } - return length, addr, true + return n, addr, true } -// decodeUTF16LE turns the raw bytes of a UNICODE_STRING buffer into a Go -// string. A trailing NUL is dropped: the PEB's DosPath is usually NUL-padded -// even though Length excludes the terminator, and a stray one would otherwise -// travel all the way into a group name. -func decodeUTF16LE(b []byte) string { - units := make([]uint16, 0, len(b)/2) - for i := 0; i+1 < len(b); i += 2 { - units = append(units, uint16(b[i])|uint16(b[i+1])<<8) +// utf16Text decodes the UTF-16LE bytes of a DosPath. The PEB usually pads the +// buffer with a NUL even though Length excludes it; the terminator is dropped +// so it cannot travel into a group name. A trailing odd byte has no character +// to decode and is ignored. +func utf16Text(b []byte) string { + units := make([]uint16, len(b)/2) + for i := range units { + units[i] = binary.LittleEndian.Uint16(b[2*i:]) } for len(units) > 0 && units[len(units)-1] == 0 { units = units[:len(units)-1] @@ -156,50 +102,42 @@ func decodeUTF16LE(b []byte) string { return string(utf16.Decode(units)) } -// normalizeWindowsCwd turns the PEB's spelling of a directory into the one the -// rest of option-berth uses as a map key. -// -// Two things have to go. The PEB always stores a trailing backslash -// (`C:\src\api\`), and groups.Canonical — which is filepath.Clean plus -// EvalSymlinks — never produces one, so an unstripped path is a second entry -// for the same directory and the group silently splits in two. And the -// extended-length `\\?\` prefix, which shows up for processes started with a -// long path, is not a spelling any other tool prints: `\\?\C:\src` and -// `C:\src` are the same directory and must not be two groups. `\\?\UNC\host\share` -// is the same escape for a network path and unfolds back to `\\host\share`. -// -// A bare drive root keeps its backslash: `C:\` is the root of C:, while `C:` -// on its own means "whatever directory that drive is parked at", which is a -// different — and unstable — place. +// normalizeWindowsCwd turns the PEB's spelling of a directory into the key the +// rest of option-berth compares. Two decorations have to go: the trailing +// separator the PEB always stores (groups.Canonical never produces one, so it +// would split one project into two groups), and the extended-length `\\?\` +// prefix that long-path launches carry (`\\?\UNC\host\share` unfolds back to +// `\\host\share`). A drive root keeps its separator: `C:\` is fixed, while a +// bare `C:` means wherever that drive happens to be parked. func normalizeWindowsCwd(raw string) string { s := strings.TrimSpace(raw) - if s == "" { - return "" + switch { + case strings.HasPrefix(s, `\\?\UNC\`): + s = `\\` + s[len(`\\?\UNC\`):] + case strings.HasPrefix(s, `\\?\`): + s = s[len(`\\?\`):] } - if rest, found := strings.CutPrefix(s, `\\?\`); found { - if unc, isUNC := strings.CutPrefix(rest, `UNC\`); isUNC { - s = `\\` + unc - } else { - s = rest - } + if isDriveRoot(s) || len(s) < 2 { + return s } - for len(s) > 1 && (s[len(s)-1] == '\\' || s[len(s)-1] == '/') { - if isDriveRoot(s) { - break - } - s = s[:len(s)-1] + trimmed := strings.TrimRight(s, `\/`) + switch { + case trimmed == s: + return s + case trimmed == "": + return `\` + case len(trimmed) == 2 && trimmed[1] == ':': + // A root written with redundant separators keeps being a root. + return trimmed + `\` } - return s + return trimmed } // isDriveRoot reports whether s is exactly `X:\` or `X:/`. func isDriveRoot(s string) bool { - if len(s) != 3 || s[1] != ':' { - return false - } - if s[2] != '\\' && s[2] != '/' { + if len(s) != 3 || s[1] != ':' || (s[2] != '\\' && s[2] != '/') { return false } - c := s[0] - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + c := s[0] | 0x20 + return c >= 'a' && c <= 'z' } diff --git a/engine/internal/ports/cwd_peb_test.go b/engine/internal/ports/cwd_peb_test.go index b839faea..6fa86a46 100644 --- a/engine/internal/ports/cwd_peb_test.go +++ b/engine/internal/ports/cwd_peb_test.go @@ -1,245 +1,250 @@ package ports import ( + "encoding/binary" "strings" "testing" + "unicode/utf16" "unsafe" ) -// The PEB walk is pointer arithmetic over another process's memory: get an -// offset wrong and ReadProcessMemory happily returns bytes that decode into a -// plausible path from the wrong field. Nothing here needs Windows — the -// geometry and the decoding are the same numbers on any host — so it is pinned -// twice: against the offsets Go derives from the mirror structs, and against -// the constants a debugger prints for ntdll on a 64-bit machine. +// Every hop of the PEB walk is decodable without Windows, so these tests pin +// the ABI constants and each decoder here, on the developer's machine, and +// then compose them over a synthetic process image. A wrong offset would make +// ReadProcessMemory return real bytes from the wrong field, so the numbers are +// checked against the ones ntdll's headers give, not only against each other. -func TestNativeLayoutMatchesTheHardCoded64BitGeometry(t *testing.T) { - if unsafe.Sizeof(uintptr(0)) != 8 { - t.Skip("the 64-bit table only describes a 64-bit host") - } - got := nativeLayout() - if got != layout64 { - t.Fatalf("nativeLayout() = %+v, want %+v", got, layout64) - } - // Spelled out again so a change to layout64 cannot quietly agree with a - // change to the mirror structs. - if got.processParameters != 0x20 { - t.Errorf("PEB.ProcessParameters = %#x, want 0x20", got.processParameters) - } - if got.currentDirectory != 0x38 { - t.Errorf("RTL_USER_PROCESS_PARAMETERS.CurrentDirectory = %#x, want 0x38", got.currentDirectory) +func TestWindowsABIGeometry(t *testing.T) { + cases := []struct { + name string + g winDirGeometry + params uintptr + curDir uintptr + }{ + {"64-bit", winDir64, 0x20, 0x38}, + {"32-bit", winDir32, 0x10, 0x24}, } - if got.unicodeBuffer != 0x08 || got.unicodeSize != 0x10 { - t.Errorf("UNICODE_STRING buffer/size = %#x/%#x, want 0x8/0x10", got.unicodeBuffer, got.unicodeSize) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.g.params != tc.params { + t.Errorf("PEB.ProcessParameters = %#x, want %#x", tc.g.params, tc.params) + } + if tc.g.currentDir != tc.curDir { + t.Errorf("CurrentDirectory.DosPath = %#x, want %#x", tc.g.currentDir, tc.curDir) + } + if got := tc.g.headerSize(); got != 2*tc.g.wordsize { + t.Errorf("headerSize = %d, want %d", got, 2*tc.g.wordsize) + } + if got := tc.g.bufferOffset(); got != uintptr(tc.g.wordsize) { + t.Errorf("bufferOffset = %d, want %d", got, tc.g.wordsize) + } + }) } } -func TestLayout32IsTheHalfWidthGeometry(t *testing.T) { - want := pebLayout{ptrSize: 4, processParameters: 0x10, currentDirectory: 0x24, unicodeBuffer: 0x04, unicodeSize: 0x08} - if layout32 != want { - t.Fatalf("layout32 = %+v, want %+v", layout32, want) +func TestSelfGeometryFollowsThisBinary(t *testing.T) { + want := winDir32 + if unsafe.Sizeof(uintptr(0)) == 8 { + want = winDir64 + } + if got := selfGeometry(); got != want { + t.Fatalf("selfGeometry() = %+v, want %+v", got, want) } } -func TestPointerAtDecodesLittleEndianAndRefusesShortReads(t *testing.T) { +func TestUintptrValueDecodesLittleEndian(t *testing.T) { buf := []byte{ - 0xEF, 0xBE, 0xAD, 0xDE, 0x00, 0x00, 0x00, 0x00, // 0x00000000deadbeef - 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x00, // 0x0077665544332211 - } - if got, ok := layout64.pointerAt(buf, 0); !ok || got != 0xdeadbeef { - t.Errorf("pointerAt(0) = %#x, %v; want 0xdeadbeef, true", got, ok) + 0xEF, 0xBE, 0xAD, 0xDE, 0x00, 0x00, 0x00, 0x00, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x00, } - if got, ok := layout64.pointerAt(buf, 8); !ok || got != 0x0077665544332211 { - t.Errorf("pointerAt(8) = %#x, %v; want 0x77665544332211, true", got, ok) + cases := []struct { + name string + at uintptr + wordsize int + want uint64 + }{ + {"64-bit low", 0, 8, 0xdeadbeef}, + {"64-bit high", 8, 8, 0x0077665544332211}, + {"32-bit", 0, 4, 0xdeadbeef}, + {"word size 3 is not a pointer", 0, 3, 0}, } - if got, ok := layout32.pointerAt(buf, 0); !ok || got != 0xdeadbeef { - t.Errorf("32-bit pointerAt(0) = %#x, %v; want 0xdeadbeef, true", got, ok) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := uintptrValue(buf, tc.at, tc.wordsize) + if tc.want == 0 { + if ok { + t.Fatalf("uintptrValue(%d, %d) = %#x, want refusal", tc.at, tc.wordsize, got) + } + return + } + if !ok || got != tc.want { + t.Fatalf("uintptrValue(%d, %d) = %#x, %v; want %#x, true", tc.at, tc.wordsize, got, ok, tc.want) + } + }) } - // A refused or truncated ReadProcessMemory arrives as a short buffer, and - // half a pointer must never decode to a usable address. - if _, ok := layout64.pointerAt(buf[:7], 0); ok { - t.Error("pointerAt accepted a 7-byte buffer for a 64-bit pointer") + // A refused remote read arrives as a short buffer; a partial pointer must + // never decode. + if _, ok := uintptrValue(buf[:7], 0, 8); ok { + t.Error("a 7-byte buffer was accepted as a 64-bit pointer") } - if _, ok := layout64.pointerAt(buf, 9); ok { - t.Error("pointerAt read past the end of the buffer") + if _, ok := uintptrValue(buf, 9, 8); ok { + t.Error("a pointer was read past the end of the buffer") } } -// synthUnicodeString builds the 16 bytes a 64-bit UNICODE_STRING occupies. -func synthUnicodeString(length, maxLength uint16, buffer uint64) []byte { - b := make([]byte, layout64.unicodeSize) +// unicodeHeader lays out the bytes of a UNICODE_STRING: two 16-bit lengths and +// one pointer of the geometry's width. +func unicodeHeader(g winDirGeometry, length, capacity uint16, data uint64) []byte { + b := make([]byte, g.headerSize()) b[0], b[1] = byte(length), byte(length>>8) - b[2], b[3] = byte(maxLength), byte(maxLength>>8) - for i := 0; i < 8; i++ { - b[int(layout64.unicodeBuffer)+i] = byte(buffer >> (8 * i)) + b[2], b[3] = byte(capacity), byte(capacity>>8) + for i := 0; i < g.wordsize; i++ { + b[int(g.bufferOffset())+i] = byte(data >> (8 * i)) } return b } -func TestUnicodeStringAtReadsLengthAndBuffer(t *testing.T) { - // C:\src\api is 10 characters, so Length is 20 bytes — the field counts - // bytes, not characters, and reading it as characters would truncate the - // path in half. - us := synthUnicodeString(20, 22, 0x00007ffe12340000) - length, addr, ok := layout64.unicodeStringAt(us, 0) +func TestDosPathReadsLengthAndBuffer(t *testing.T) { + // C:\src\api is ten characters, so Length is twenty bytes. Reading it as + // a character count would cut the path in half. + header := unicodeHeader(winDir64, 20, 22, 0x00007ffe12340000) + size, data, ok := winDir64.dosPath(header) if !ok { - t.Fatal("unicodeStringAt refused a well-formed UNICODE_STRING") + t.Fatal("a well-formed UNICODE_STRING was refused") } - if length != 20 { - t.Errorf("length = %d, want 20 bytes", length) + if size != 20 { + t.Errorf("size = %d, want 20 bytes", size) } - if addr != 0x00007ffe12340000 { - t.Errorf("buffer = %#x, want 0x7ffe12340000", addr) - } -} - -func TestUnicodeStringAtHonoursTheCurrentDirectoryOffset(t *testing.T) { - // The real read lands the whole RTL_USER_PROCESS_PARAMETERS prefix in the - // buffer, so the UNICODE_STRING is decoded at CurrentDirectory, not at 0. - params := make([]byte, int(layout64.currentDirectory)+layout64.unicodeSize) - copy(params[layout64.currentDirectory:], synthUnicodeString(8, 10, 0xCAFE0000)) - length, addr, ok := layout64.unicodeStringAt(params, layout64.currentDirectory) - if !ok || length != 8 || addr != 0xCAFE0000 { - t.Fatalf("unicodeStringAt at CurrentDirectory = %d, %#x, %v; want 8, 0xcafe0000, true", length, addr, ok) - } - // Decoding at 0 instead reads MaximumLength/Flags as a length and a null - // pointer as a buffer: exactly the silent-garbage failure the offset - // prevents. - if _, _, ok := layout64.unicodeStringAt(params, 0); ok { - t.Error("a UNICODE_STRING decoded out of the zeroed header was accepted") + if data != 0x00007ffe12340000 { + t.Errorf("buffer = %#x, want 0x7ffe12340000", data) } } -func TestUnicodeStringAtRejectsImplausibleValues(t *testing.T) { +func TestDosPathRejectsUnusableHeaders(t *testing.T) { + full := unicodeHeader(winDir64, 20, 22, 0x1000) cases := []struct { - name string - buf []byte + name string + header []byte }{ - {"truncated", synthUnicodeString(20, 22, 0x1000)[:8]}, - {"null buffer", synthUnicodeString(20, 22, 0)}, - {"zero length", synthUnicodeString(0, 22, 0x1000)}, - {"odd length", synthUnicodeString(21, 22, 0x1000)}, + {"short header", full[:10]}, + {"null buffer", unicodeHeader(winDir64, 20, 22, 0)}, + {"zero length", unicodeHeader(winDir64, 0, 22, 0x1000)}, + {"odd length", unicodeHeader(winDir64, 21, 22, 0x1000)}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if _, _, ok := layout64.unicodeStringAt(tc.buf, 0); ok { - t.Error("unicodeStringAt accepted it") + if _, _, ok := winDir64.dosPath(tc.header); ok { + t.Error("an unusable header was accepted") } }) } } -func TestDecodeUTF16LE(t *testing.T) { - encode := func(s string, trailingNULs int) []byte { - var b []byte - for _, r := range []rune(s) { - if r > 0xFFFF { - r -= 0x10000 - hi := 0xD800 + uint16(r>>10) - lo := 0xDC00 + uint16(r&0x3FF) - b = append(b, byte(hi), byte(hi>>8), byte(lo), byte(lo>>8)) - continue - } - b = append(b, byte(r), byte(r>>8)) - } - for i := 0; i < trailingNULs; i++ { - b = append(b, 0, 0) - } - return b +// utf16LE encodes s the way the PEB stores a path, with an optional NUL pad. +func utf16LE(s string, pad int) []byte { + units := append(utf16.Encode([]rune(s)), make([]uint16, pad)...) + b := make([]byte, 2*len(units)) + for i, u := range units { + binary.LittleEndian.PutUint16(b[2*i:], u) } - cases := []struct { - name string - in []byte - want string - }{ - {"ascii path", encode(`C:\src\api`, 0), `C:\src\api`}, - {"NUL terminated", encode(`C:\src\api`, 1), `C:\src\api`}, - {"non-ascii", encode(`C:\Users\Example User\Bürö`, 0), `C:\Users\Example User\Bürö`}, - {"surrogate pair", encode("C:\\emoji\\🚀", 0), "C:\\emoji\\🚀"}, + return b +} + +type utf16Case struct { + label string + in []byte + want string +} + +func TestUTF16Text(t *testing.T) { + cases := []utf16Case{ + {"plain path", utf16LE(`C:\src\api`, 0), `C:\src\api`}, + {"NUL padded", utf16LE(`C:\src\api`, 1), `C:\src\api`}, + {"non-ascii", utf16LE(`C:\Users\Example User\Bürö`, 0), `C:\Users\Example User\Bürö`}, + {"surrogate pair", utf16LE("C:\\emoji\\🚀", 0), "C:\\emoji\\🚀"}, {"empty", nil, ""}, - {"odd trailing byte is ignored", []byte{0x43, 0x00, 0x3A}, "C"}, + {"odd trailing byte", []byte{0x43, 0x00, 0x3A}, "C"}, } for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := decodeUTF16LE(tc.in); got != tc.want { - t.Errorf("decodeUTF16LE = %q, want %q", got, tc.want) + t.Run(tc.label, func(t *testing.T) { + if got := utf16Text(tc.in); got != tc.want { + t.Errorf("utf16Text = %q, want %q", got, tc.want) } }) } } +type textCase struct{ label, in, want string } + func TestNormalizeWindowsCwd(t *testing.T) { - cases := []struct { - name string - in string - want string - }{ - // The PEB always stores the trailing backslash; groups.Canonical never - // does, so leaving it on splits one project into two groups. - {"trailing backslash", `C:\src\api\`, `C:\src\api`}, - {"no trailing backslash", `C:\src\api`, `C:\src\api`}, - {"drive root keeps its backslash", `C:\`, `C:\`}, + cases := []textCase{ + {"trailing separator", `C:\src\api\`, `C:\src\api`}, + {"already clean", `C:\src\api`, `C:\src\api`}, + {"drive root", `C:\`, `C:\`}, {"lowercase drive root", `d:\`, `d:\`}, + {"drive-relative path is left alone", `C:`, `C:`}, {"extended-length prefix", `\\?\C:\src\api\`, `C:\src\api`}, {"extended-length UNC", `\\?\UNC\build\share\api\`, `\\build\share\api`}, {"plain UNC", `\\build\share\api\`, `\\build\share\api`}, - {"space padded", " C:\\src\\api\\ ", `C:\src\api`}, + {"surrounding spaces", " C:\\src\\api\\ ", `C:\src\api`}, {"empty", "", ""}, } for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { + t.Run(tc.label, func(t *testing.T) { got := normalizeWindowsCwd(tc.in) - if got != tc.want { + switch { + case got != tc.want: t.Errorf("normalizeWindowsCwd(%q) = %q, want %q", tc.in, got, tc.want) - } - if strings.HasPrefix(got, `\\?\`) { - t.Errorf("normalizeWindowsCwd(%q) kept the \\\\?\\ prefix", tc.in) + case strings.HasPrefix(got, `\\?\`): + t.Errorf("normalizeWindowsCwd(%q) left the extended-length prefix on", tc.in) } }) } } -// TestPEBWalkOverASyntheticProcessImage is the whole three-hop walk done over a -// byte slice standing in for another process's address space: it proves the -// offsets compose, not just that each one is right on its own. +// fakeProcessImage is a byte slice standing in for another process's address +// space, rooted at base. It lets the three hops be composed here rather than +// trusting each decoder in isolation. +type fakeProcessImage struct { + base uint64 + mem []byte +} + +func (f fakeProcessImage) at(addr uint64, n int) []byte { + off := addr - f.base + return f.mem[off : off+uint64(n)] +} + +func (f fakeProcessImage) putPointer(addr, value uint64, wordsize int) { + target := f.at(addr, wordsize) + for i := 0; i < wordsize; i++ { + target[i] = byte(value >> (8 * i)) + } +} + +func (f fakeProcessImage) putUTF16(addr uint64, s string) { + target := f.at(addr, len(utf16LE(s, 0))) + copy(target, utf16LE(s, 0)) +} + func TestPEBWalkOverASyntheticProcessImage(t *testing.T) { - const ( - pebAddr = uint64(0x00007FF700000000) - paramsAddr = uint64(0x00007FF700010000) - pathAddr = uint64(0x00007FF700020000) - base = pebAddr - ) - l := layout64 - mem := make([]byte, 0x30000) - read := func(addr uint64, n int) []byte { - off := addr - base - return mem[off : off+uint64(n)] - } - writePtr := func(addr, value uint64) { - b := read(addr, l.ptrSize) - for i := 0; i < l.ptrSize; i++ { - b[i] = byte(value >> (8 * i)) - } - } - - // PEB.ProcessParameters -> params, params.CurrentDirectory -> the string. - writePtr(pebAddr+uint64(l.processParameters), paramsAddr) - copy(read(paramsAddr+uint64(l.currentDirectory), l.unicodeSize), - synthUnicodeString(uint16(len(`C:\src\api`)*2), uint16(len(`C:\src\api`)*2+2), pathAddr)) - pathBytes := read(pathAddr, len(`C:\src\api`)*2) - for i, r := range []rune(`C:\src\api`) { - pathBytes[i*2], pathBytes[i*2+1] = byte(r), byte(r>>8) - } - - params, ok := l.pointerAt(read(pebAddr+uint64(l.processParameters), l.ptrSize), 0) + pebAddr, paramsAddr, pathAddr := uint64(0x7FF700000000), uint64(0x7FF700010000), uint64(0x7FF700020000) + g := winDir64 + image := fakeProcessImage{base: pebAddr, mem: make([]byte, 0x30000)} + image.putPointer(pebAddr+uint64(g.params), paramsAddr, g.wordsize) + copy(image.at(paramsAddr+uint64(g.currentDir), g.headerSize()), + unicodeHeader(g, uint16(len(`C:\src\api`)*2), uint16(len(`C:\src\api`)*2+2), pathAddr)) + image.putUTF16(pathAddr, `C:\src\api`) + + params, ok := uintptrValue(image.at(pebAddr+uint64(g.params), g.wordsize), 0, g.wordsize) if !ok || params != paramsAddr { t.Fatalf("ProcessParameters = %#x, %v; want %#x", params, ok, paramsAddr) } - length, addr, ok := l.unicodeStringAt(read(params+uint64(l.currentDirectory), l.unicodeSize), 0) - if !ok || addr != pathAddr { - t.Fatalf("CurrentDirectory.DosPath = %d, %#x, %v; want %#x", length, addr, ok, pathAddr) + size, data, ok := g.dosPath(image.at(params+uint64(g.currentDir), g.headerSize())) + if !ok || data != pathAddr { + t.Fatalf("CurrentDirectory.DosPath = %d, %#x, %v; want buffer %#x", size, data, ok, pathAddr) } - if got := normalizeWindowsCwd(decodeUTF16LE(read(addr, length))); got != `C:\src\api` { + if got := normalizeWindowsCwd(utf16Text(image.at(data, size))); got != `C:\src\api` { t.Fatalf("the walk produced %q, want %q", got, `C:\src\api`) } } diff --git a/engine/internal/ports/cwd_windows.go b/engine/internal/ports/cwd_windows.go index 5eed95d6..4e443cc9 100644 --- a/engine/internal/ports/cwd_windows.go +++ b/engine/internal/ports/cwd_windows.go @@ -9,44 +9,33 @@ import ( "golang.org/x/sys/windows" ) -// batchGetCwds returns pid -> working directory by reading each process's PEB. +// batchGetCwds resolves the working directory of every pid it can, and omits +// the rest. // -// Windows has no lsof and no /proc: the current directory of another process is -// only readable out of that process's own address space. For each pid the -// sequence is -// -// OpenProcess(PROCESS_QUERY_INFORMATION|PROCESS_VM_READ) -// NtQueryInformationProcess(ProcessBasicInformation) -> PebBaseAddress -// ReadProcessMemory(peb + ProcessParameters) -> params address -// ReadProcessMemory(params + CurrentDirectory) -> UNICODE_STRING -// ReadProcessMemory(UNICODE_STRING.Buffer) -> the UTF-16 path -// -// and every one of those steps is allowed to fail. System processes, services -// running as another user and anything protected deny the handle outright, and -// a process may exit between the scan and this call. Following the rule the -// lsof path already lives by, a pid that refuses is skipped and the batch -// carries on: partial results are results, and a scan that returned nothing -// because one system process said no would be worse than one that named -// nine listeners out of ten. +// Windows has neither lsof nor /proc: another process's current directory is +// only readable out of that process's own memory, so a pid can refuse at any +// of four steps. Protected and cross-user processes deny the handle, and a +// process may exit between the scan that listed it and this call. The batch +// keeps going: naming nine listeners out of ten is worth more than naming none +// because a system process said no, and an omitted pid stays unknown. // // Limitation: a 32-bit option-berth cannot read a 64-bit process. Its -// NtQueryInformationProcess is the WOW64 thunk, which reports the 32-bit view, -// and the 64-bit target's structures live above the 4 GB its pointers can -// address. Those pids are skipped and get "" — the same answer they had before -// this file existed. A 64-bit option-berth has no such gap: it reads 64-bit processes -// and WOW64 32-bit ones alike, because a WOW64 process keeps a populated -// 64-bit PEB next to its 32-bit one. +// NtQueryInformationProcess is the WOW64 thunk that reports the 32-bit view, +// and the target's structures sit above the 4 GB its pointers can address. +// Those pids are skipped and get no entry — the same answer they had before +// this file existed. A 64-bit option-berth reads 64-bit processes and WOW64 +// 32-bit ones alike. func batchGetCwds(pids []int) map[int]string { return batchGetCwdsContext(context.Background(), pids) } func batchGetCwdsContext(ctx context.Context, pids []int) map[int]string { - result := make(map[int]string, len(pids)) + out := make(map[int]string, len(pids)) if len(pids) == 0 || ctx.Err() != nil { - return result + return out } - layout := nativeLayout() - wow64 := selfIsWow64() + g := selfGeometry() + selfIsWow64 := isWow64Self() for _, pid := range pids { if ctx.Err() != nil { return nil @@ -54,92 +43,77 @@ func batchGetCwdsContext(ctx context.Context, pids []int) map[int]string { if pid <= 0 { continue } - if cwd := processCwd(uint32(pid), layout, wow64); cwd != "" { - result[pid] = cwd + if dir := remoteCwd(uint32(pid), g, selfIsWow64); dir != "" { + out[pid] = dir } } if ctx.Err() != nil { return nil } - return result + return out } -// selfIsWow64 reports whether this option-berth is a 32-bit binary running on 64-bit -// Windows. A native 32-bit Windows has no 64-bit processes to fail on, and a -// 64-bit option-berth is never WOW64, so this is only ever true in the one build that -// has the limitation documented above. -func selfIsWow64() bool { +// isWow64Self reports whether this binary is 32-bit code running on 64-bit +// Windows. A 32-bit build on native 32-bit Windows has no 64-bit process to +// fail on, and a 64-bit build is never WOW64; only that one combination has +// the limitation described above. +func isWow64Self() bool { if unsafe.Sizeof(uintptr(0)) == 8 { return false } - var is bool - if err := windows.IsWow64Process(windows.CurrentProcess(), &is); err != nil { + var wow64 bool + if err := windows.IsWow64Process(windows.CurrentProcess(), &wow64); err != nil { return false } - return is + return wow64 } -// processCwd resolves one pid, returning "" for every failure. -func processCwd(pid uint32, layout pebLayout, selfWow64 bool) string { - h, err := openForRead(pid) +// remoteCwd walks one process's PEB, returning "" if any hop fails. +func remoteCwd(pid uint32, g winDirGeometry, selfIsWow64 bool) string { + h, err := openProcessForRead(pid) if err != nil { return "" } defer windows.CloseHandle(h) - if selfWow64 && !targetIsWow64(h) { - // A 32-bit option-berth looking at a 64-bit process: unsupported, see above. - return "" - } - - var pbi windows.PROCESS_BASIC_INFORMATION - var retLen uint32 - if err := windows.NtQueryInformationProcess(h, windows.ProcessBasicInformation, - unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), &retLen); err != nil { - return "" - } - peb := uintptr(unsafe.Pointer(pbi.PebBaseAddress)) - if peb == 0 { + if selfIsWow64 && !isWow64Process(h) { + // 32-bit reader, 64-bit target: unsupported, see the limitation above. return "" } - // PEB.ProcessParameters - buf := make([]byte, layout.ptrSize) - if !readMemory(h, peb+layout.processParameters, buf) { + peb, ok := pebAddress(h) + if !ok { return "" } - params, ok := layout.pointerAt(buf, 0) - if !ok || params == 0 { + params, ok := readPointer(h, peb+g.params, g.wordsize) + if !ok { return "" } - // RTL_USER_PROCESS_PARAMETERS.CurrentDirectory.DosPath - us := make([]byte, layout.unicodeSize) - if !readMemory(h, uintptr(params)+layout.currentDirectory, us) { + header := make([]byte, g.headerSize()) + if !readRemoteMemory(h, params+g.currentDir, header) { return "" } - length, addr, ok := layout.unicodeStringAt(us, 0) + size, data, ok := g.dosPath(header) if !ok { return "" } - // UNICODE_STRING.Buffer - path := make([]byte, length) - if !readMemory(h, uintptr(addr), path) { + path := make([]byte, size) + if !readRemoteMemory(h, uintptr(data), path) { return "" } - raw := decodeUTF16LE(path) - if raw == "" { + dir := utf16Text(path) + if dir == "" { return "" } - return normalizeWindowsCwd(longPathName(raw)) + return normalizeWindowsCwd(longPathName(dir)) } -// openForRead opens a process for the reads above. PROCESS_QUERY_INFORMATION -// is what NtQueryInformationProcess documents; PROCESS_QUERY_LIMITED_INFORMATION -// is enough on Vista and later and is granted in places the full right is not, -// so it is worth a second attempt before giving up on a pid. -func openForRead(pid uint32) (windows.Handle, error) { +// openProcessForRead opens the handle the walk needs. The documented right is +// PROCESS_QUERY_INFORMATION, but from Vista on PROCESS_QUERY_LIMITED_INFORMATION +// is enough and is granted in more places, so it is worth one retry. +func openProcessForRead(pid uint32) (windows.Handle, error) { h, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, pid) if err == nil { return h, nil @@ -147,50 +121,81 @@ func openForRead(pid uint32) (windows.Handle, error) { return windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ, false, pid) } -// targetIsWow64 reports whether the process behind h is a 32-bit process on -// 64-bit Windows. A handle that cannot answer is treated as 64-bit, i.e. as one -// this build must not try to read. -func targetIsWow64(h windows.Handle) bool { - var is bool - if err := windows.IsWow64Process(h, &is); err != nil { +// pebAddress asks the kernel where the target's PEB begins. The call returns +// the address even when reading it later will be refused, so the walk must +// still treat every following hop as fallible. +func pebAddress(h windows.Handle) (uintptr, bool) { + var info windows.PROCESS_BASIC_INFORMATION + var written uint32 + size := uint32(unsafe.Sizeof(info)) + if err := windows.NtQueryInformationProcess(h, windows.ProcessBasicInformation, + unsafe.Pointer(&info), size, &written); err != nil { + return 0, false + } + if info.PebBaseAddress == nil { + return 0, false + } + return uintptr(unsafe.Pointer(info.PebBaseAddress)), true +} + +// readPointer reads one pointer-sized field out of the target's memory. +func readPointer(h windows.Handle, at uintptr, wordsize int) (uintptr, bool) { + buf := make([]byte, wordsize) + if !readRemoteMemory(h, at, buf) { + return 0, false + } + value, ok := uintptrValue(buf, 0, wordsize) + if !ok || value == 0 { + return 0, false + } + return uintptr(value), true +} + +// isWow64Process reports whether h is a 32-bit process on 64-bit Windows. A +// handle that cannot answer counts as 64-bit, the direction this build must +// not read. +func isWow64Process(h windows.Handle) bool { + var wow64 bool + if err := windows.IsWow64Process(h, &wow64); err != nil { return false } - return is + return wow64 } -// readMemory fills buf from the target's address space, insisting on a complete -// read: a partial one means the address was wrong, and half a pointer decodes -// to a plausible-looking lie. -func readMemory(h windows.Handle, addr uintptr, buf []byte) bool { +// readRemoteMemory fills buf completely or reports failure: a partial read +// means the address was wrong, and half a pointer decodes to a lie that looks +// plausible. +func readRemoteMemory(h windows.Handle, at uintptr, buf []byte) bool { if len(buf) == 0 { return false } var read uintptr - if err := windows.ReadProcessMemory(h, addr, &buf[0], uintptr(len(buf)), &read); err != nil { + if err := windows.ReadProcessMemory(h, at, &buf[0], uintptr(len(buf)), &read); err != nil { return false } return int(read) == len(buf) } // longPathName expands an 8.3 short name (`C:\PROGRA~1\api`) to its long -// spelling. The PEB stores whatever the process was launched with, while -// groups.Canonical runs filepath.EvalSymlinks, which on Windows always reports -// the long form — so without this the same directory would key the group index -// twice. A path that no longer exists, or one GetLongPathName refuses, is kept -// as it was: a short-name group is still better than no group. +// spelling. A process keeps whatever it was launched with, while +// groups.Canonical resolves symlinks and reports the long form on Windows, so +// without this the same directory would key the group index twice. A path +// that no longer exists, or that GetLongPathName refuses, is returned as it +// was: a short-name group is still better than no group. func longPathName(path string) string { - p, err := windows.UTF16PtrFromString(path) - if err != nil { + wide, convErr := windows.UTF16PtrFromString(path) + if convErr != nil { return path } - n, err := windows.GetLongPathName(p, nil, 0) - if err != nil || n == 0 { + needed, sizeErr := windows.GetLongPathName(wide, nil, 0) + if sizeErr != nil || needed == 0 { return path } - buf := make([]uint16, n) - n, err = windows.GetLongPathName(p, &buf[0], n) - if err != nil || n == 0 || int(n) > len(buf) { + long := make([]uint16, needed) + written, fillErr := windows.GetLongPathName(wide, &long[0], needed) + switch { + case fillErr != nil, written == 0, written > needed: return path } - return windows.UTF16ToString(buf[:n]) + return windows.UTF16ToString(long[:written]) } diff --git a/engine/internal/ports/displayname_test.go b/engine/internal/ports/displayname_test.go index 1b7a325c..2dd24f46 100644 --- a/engine/internal/ports/displayname_test.go +++ b/engine/internal/ports/displayname_test.go @@ -14,268 +14,107 @@ func TestCachedSignalsCoverRejectsNewPID(t *testing.T) { } } -func TestResolveProcessName(t *testing.T) { - tests := []struct { - name string - cmd string - parentCmd string - cwd string - want string - }{ - // .app bundles - { - name: ".app bundle", - cmd: "/Applications/Visual Studio Code.app/Contents/MacOS/Electron --type=renderer", - want: "Visual Studio Code", - }, - - // Generic Go binary with cwd context - { - name: "Go binary in tmp/main with cwd", - cmd: "/Users/me/projects/example-org-api/tmp/main", - cwd: "/Users/me/projects/example-org-api", - want: "example-org-api/main", - }, - { - name: "Go binary without cwd falls back to basename", - cmd: "/Users/me/projects/example-org-api/tmp/main", - want: "main", - }, - { - name: "binary in noise dir uses ancestor", - cmd: "/Users/me/projects/myapp/build/server", - cwd: "/Users/me/projects/myapp/build", - want: "myapp/server", - }, - - // Python -c (multiprocessing.spawn worker) - { - name: "python -c with parent uvicorn", - cmd: "/usr/bin/python -c from multiprocessing.spawn import spawn_main; spawn_main(...)", - parentCmd: "/Users/me/.venv/bin/python /Users/me/.venv/bin/uvicorn server:app --reload --port 8001", - want: "uvicorn", - }, - { - name: "python -c with no parent falls back to interpreter", - cmd: "python -c from foo import bar", - parentCmd: "", - want: "python", - }, +// nameCase is one command line and the name the display layer derives from it. +// parent and cwd are optional context: a worker's parent names the service it +// belongs to, and the working directory separates two checkouts that run the +// same binary. +type nameCase struct { + label string + cmd string + // parent and cwd are empty when the command line alone decides the name. + parent string + cwd string + want string +} - // uv / poetry runners - { - name: "uv run uvicorn", - cmd: "uv run uvicorn server:app --port 8001", - want: "uvicorn", - }, - { - name: "poetry run gunicorn", - cmd: "poetry run gunicorn app:application", - want: "gunicorn", - }, - { - name: "npx next dev", - cmd: "npx next dev", - want: "next", - }, +func checkNames(t *testing.T, cases []nameCase) { + t.Helper() + for _, tc := range cases { + t.Run(tc.label, func(t *testing.T) { + if got := resolveProcessName(tc.cmd, tc.parent, tc.cwd); got != tc.want { + t.Errorf("resolveProcessName(%q, parent %q, cwd %q) = %q, want %q", tc.cmd, tc.parent, tc.cwd, got, tc.want) + } + }) + } +} - // python script vs module - { - name: "python /path/script.py", - cmd: "/usr/bin/python /home/me/app/server.py --port 8000", - want: "server.py", - }, - { - name: "python -m module", - cmd: "python -m uvicorn server:app", - want: "uvicorn", - }, - { - name: "python -m dotted.module", - cmd: "python -m my.app.main", - want: "my.app.main", - }, +func TestResolveProcessNameAppBundles(t *testing.T) { + checkNames(t, []nameCase{ + {label: "app bundle", cmd: "/Applications/Visual Studio Code.app/Contents/MacOS/Electron --type=renderer", want: "Visual Studio Code"}, + }) +} - // node - { - name: "node script.js", - cmd: "/usr/local/bin/node /app/server.js", - want: "server.js", - }, +func TestResolveProcessNameBuildOutput(t *testing.T) { + checkNames(t, []nameCase{ + {label: "go run with cwd", cmd: "/Users/me/projects/example-org-api/tmp/main", cwd: "/Users/me/projects/example-org-api", want: "example-org-api/main"}, + {label: "go run without cwd", cmd: "/Users/me/projects/example-org-api/tmp/main", want: "main"}, + {label: "noise directory", cmd: "/Users/me/projects/myapp/build/server", cwd: "/Users/me/projects/myapp/build", want: "myapp/server"}, + {label: "cargo release directory", cmd: "/projects/myapp/target/release/myapp-server", cwd: "/projects/myapp/target/release", want: "myapp/myapp-server"}, + }) +} - // java - { - name: "java -cp classpath ClassName", - cmd: "/usr/bin/java -cp dep1.jar:dep2.jar:dep3.jar com.myapp.SomeClass", - want: "com.myapp.SomeClass", - }, - { - name: "java -classpath classpath ClassName", - cmd: "java -classpath /path/to/dep1.jar:/path/to/dep2.jar com.example.Main", - want: "com.example.Main", - }, - { - name: "java -jar app.jar", - cmd: "java -jar /path/to/app.jar", - want: "app.jar", - }, - { - name: "java with no class", - cmd: "java", - want: "java", - }, - { - name: "java --class-path long form", - cmd: "java --class-path dep1.jar:dep2.jar com.example.Main", - want: "com.example.Main", - }, - { - name: "java JVM flags before -cp", - cmd: "java -Xmx1g -Dfoo=bar -cp app.jar com.example.Main", - want: "com.example.Main", - }, - { - name: "java main class with trailing args", - cmd: "java -cp app.jar com.example.Main --port 8080", - want: "com.example.Main", - }, - { - name: "java bare main class no flags", - cmd: "java com.example.Main", - want: "com.example.Main", - }, - { - name: "java -cp at end with no value", - cmd: "java -cp", - want: "java", - }, - { - name: "java -m module/class", - cmd: "java -p mods -m com.foo/com.foo.Main", - want: "com.foo.Main", - }, - { - name: "java --module long form", - cmd: "java --module-path mods --module com.foo/com.foo.Main", - want: "com.foo.Main", - }, - { - name: "java --module=value equals form", - cmd: "java --module-path mods --module=com.foo/com.foo.Main", - want: "com.foo.Main", - }, - { - name: "java -m module only no class", - cmd: "java -m com.foo", - want: "com.foo", - }, +func TestResolveProcessNamePython(t *testing.T) { + checkNames(t, []nameCase{ + {label: "spawn worker names its parent", cmd: "/usr/bin/python -c from multiprocessing.spawn import spawn_main; spawn_main(...)", parent: "/Users/me/.venv/bin/python /Users/me/.venv/bin/uvicorn server:app --reload --port 8001", want: "uvicorn"}, + {label: "spawn worker without parent", cmd: "python -c from foo import bar", want: "python"}, + {label: "script file", cmd: "/usr/bin/python /home/me/app/server.py --port 8000", want: "server.py"}, + {label: "module", cmd: "python -m uvicorn server:app", want: "uvicorn"}, + {label: "dotted module", cmd: "python -m my.app.main", want: "my.app.main"}, + }) +} - // Cwd should not be added when name already starts with it - { - name: "no double-prefix when name already includes cwd", - cmd: "/usr/bin/myapp", - cwd: "/projects/myapp", - want: "myapp", - }, - // Cwd ignored for system home root - { - name: "skip Users home root", - cmd: "/usr/local/bin/redis-server", - cwd: "/Users", - want: "redis-server", - }, +func TestResolveProcessNameJavaScript(t *testing.T) { + checkNames(t, []nameCase{ + {label: "node script", cmd: "/usr/local/bin/node /app/server.js", want: "server.js"}, + {label: "npx runner", cmd: "npx next dev", want: "next"}, + {label: "uv runner", cmd: "uv run uvicorn server:app --port 8001", want: "uvicorn"}, + {label: "poetry runner", cmd: "poetry run gunicorn app:application", want: "gunicorn"}, + }) +} - // Cwd of generic binary in build dir walks up - { - name: "cargo target/release walks up", - cmd: "/projects/myapp/target/release/myapp-server", - cwd: "/projects/myapp/target/release", - want: "myapp/myapp-server", - }, +func TestResolveProcessNameJava(t *testing.T) { + checkNames(t, []nameCase{ + {label: "short classpath option", cmd: "/usr/bin/java -cp dep1.jar:dep2.jar:dep3.jar com.myapp.SomeClass", want: "com.myapp.SomeClass"}, + {label: "long classpath option", cmd: "java -classpath /path/to/dep1.jar:/path/to/dep2.jar com.example.Main", want: "com.example.Main"}, + {label: "jar file", cmd: "java -jar /path/to/app.jar", want: "app.jar"}, + {label: "no class at all", cmd: "java", want: "java"}, + {label: "double-dash classpath", cmd: "java --class-path dep1.jar:dep2.jar com.example.Main", want: "com.example.Main"}, + {label: "JVM flags before classpath", cmd: "java -Xmx1g -Dfoo=bar -cp app.jar com.example.Main", want: "com.example.Main"}, + {label: "trailing program arguments", cmd: "java -cp app.jar com.example.Main --port 8080", want: "com.example.Main"}, + {label: "bare main class", cmd: "java com.example.Main", want: "com.example.Main"}, + {label: "classpath option without value", cmd: "java -cp", want: "java"}, + {label: "short module option", cmd: "java -p mods -m com.foo/com.foo.Main", want: "com.foo.Main"}, + {label: "long module option", cmd: "java --module-path mods --module com.foo/com.foo.Main", want: "com.foo.Main"}, + {label: "module option with equals", cmd: "java --module-path mods --module=com.foo/com.foo.Main", want: "com.foo.Main"}, + {label: "module without class", cmd: "java -m com.foo", want: "com.foo"}, + }) +} - // Real-host cases (2026-10-01 capture, paths made generic): the names - // these must keep producing are the ones a developer saw on their - // machine, not a synthetic approximation. - { - name: "macOS app bundle ignores a home cwd", - cmd: "/Applications/Warp.app/Contents/MacOS/stable", - cwd: "/Users/dev", - want: "Warp", - }, - { - name: "macOS app bundle ignores a support-data cwd", - cmd: "/Applications/QQ.app/Contents/MacOS/QQ", - cwd: "/Users/dev/Library/Application Support/QQ/Data", - want: "QQ", - }, - { - name: "java -jar prepends the project directory", - cmd: "java -Xms512m -Xmx1280m -jar target/example-project.jar --server.port=8089", - cwd: "/Users/dev/code/example-project", - want: "example-project/example-project.jar", - }, - { - name: "node vite binary runs from the frontend directory", - cmd: "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js", - cwd: "/Users/dev/code/example-project-web", - want: "example-project-web/vite.js", - }, - { - name: "homebrew service keeps its formula directory", - cmd: "/opt/homebrew/opt/redis/bin/redis-server 127.0.0.1:6379", - cwd: "/opt/homebrew/var/db/redis", - want: "redis/redis-server", - }, - { - name: "python -m http.server names the module", - cmd: "python3 -m http.server 8000", - cwd: "/Users/dev/code/site", - want: "site/http.server", - }, - { - name: "a helper nested in an app names the app, not the helper", - cmd: "/Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper (Plugin).app/Contents/MacOS/Code Helper (Plugin) --type=utility", - cwd: "/Users/dev", - want: "Visual Studio Code", - }, - { - name: "a tool inside an app's resources names the app", - cmd: "/Applications/WorkBuddy.app/Contents/Resources/app.asar.unpacked/cli/bin/sandbox-cli --prewarm", - cwd: "/Users/dev/WorkBuddy/2026-09-30-13-15-02", - want: "WorkBuddy", - }, - { - // The JDK ships as an .app too; java running from it is still java, - // and the main class is the name that identifies the service. - name: "a bundled interpreter is not the product", - cmd: "/opt/elasticsearch/jdk.app/Contents/Home/bin/java -Xshare:auto -Des.path.home=/opt/elasticsearch -cp /opt/elasticsearch/lib/* org.elasticsearch.bootstrap.Elasticsearch", - cwd: "/opt/elasticsearch", - want: "elasticsearch/org.elasticsearch.bootstrap.Elasticsearch", - }, - { - // RabbitMQ runs as the Erlang VM; the name that means anything is - // the release script its shell parent was started with. - name: "erlang vm names its release script", - cmd: "/opt/homebrew/Cellar/erlang@28/28.5.0.5/lib/erlang/erts-16.4.0.5/bin/beam.smp -W w -MBas ageffcbf", - parentCmd: "/bin/sh /opt/homebrew/Cellar/rabbitmq/4.3.5/libexec/rabbitmq-server", - cwd: "/", - want: "rabbitmq-server", - }, - { - name: "a shell wrapping a script names the script", - cmd: "/bin/sh /opt/services/payments/run-server.sh --port 9000", - cwd: "/opt/services/payments", - want: "payments/run-server.sh", - }, - } +func TestResolveProcessNameUsesCwdOnlyAsSuffix(t *testing.T) { + checkNames(t, []nameCase{ + {label: "already carries the cwd name", cmd: "/usr/bin/myapp", cwd: "/projects/myapp", want: "myapp"}, + {label: "system home root is ignored", cmd: "/usr/local/bin/redis-server", cwd: "/Users", want: "redis-server"}, + }) +} - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := resolveProcessName(tc.cmd, tc.parentCmd, tc.cwd) - if got != tc.want { - t.Errorf("\ncmd: %q\nparentCmd: %q\ncwd: %q\ngot: %q\nwant: %q", - tc.cmd, tc.parentCmd, tc.cwd, got, tc.want) - } - }) - } +func TestResolveProcessNameRealHostCaptures(t *testing.T) { + // Captured from a real host on 2026-10-01, paths made generic. These are the + // names a developer actually saw, not a synthetic approximation. + checkNames(t, []nameCase{ + {label: "app bundle ignores a home cwd", cmd: "/Applications/Warp.app/Contents/MacOS/stable", cwd: "/Users/dev", want: "Warp"}, + {label: "app bundle ignores a support-data cwd", cmd: "/Applications/QQ.app/Contents/MacOS/QQ", cwd: "/Users/dev/Library/Application Support/QQ/Data", want: "QQ"}, + {label: "java jar prepends the project", cmd: "java -Xms512m -Xmx1280m -jar target/example-project.jar --server.port=8089", cwd: "/Users/dev/code/example-project", want: "example-project/example-project.jar"}, + {label: "vite binary from the frontend directory", cmd: "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js", cwd: "/Users/dev/code/example-project-web", want: "example-project-web/vite.js"}, + {label: "homebrew service keeps its formula", cmd: "/opt/homebrew/opt/redis/bin/redis-server 127.0.0.1:6379", cwd: "/opt/homebrew/var/db/redis", want: "redis/redis-server"}, + {label: "http.server names the module", cmd: "python3 -m http.server 8000", cwd: "/Users/dev/code/site", want: "site/http.server"}, + {label: "nested app helper names the app", cmd: "/Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper (Plugin).app/Contents/MacOS/Code Helper (Plugin) --type=utility", cwd: "/Users/dev", want: "Visual Studio Code"}, + {label: "tool in app resources names the app", cmd: "/Applications/WorkBuddy.app/Contents/Resources/app.asar.unpacked/cli/bin/sandbox-cli --prewarm", cwd: "/Users/dev/WorkBuddy/2026-09-30-13-15-02", want: "WorkBuddy"}, + // The JDK ships as an .app too; java running from it is still java, and + // the main class is the name that identifies the service. + {label: "bundled interpreter is not the product", cmd: "/opt/elasticsearch/jdk.app/Contents/Home/bin/java -Xshare:auto -Des.path.home=/opt/elasticsearch -cp /opt/elasticsearch/lib/* org.elasticsearch.bootstrap.Elasticsearch", cwd: "/opt/elasticsearch", want: "elasticsearch/org.elasticsearch.bootstrap.Elasticsearch"}, + // RabbitMQ runs as the Erlang VM; the name that means anything is the + // release script its shell parent was started with. + {label: "erlang vm names its release script", cmd: "/opt/homebrew/Cellar/erlang@28/28.5.0.5/lib/erlang/erts-16.4.0.5/bin/beam.smp -W w -MBas ageffcbf", parent: "/bin/sh /opt/homebrew/Cellar/rabbitmq/4.3.5/libexec/rabbitmq-server", cwd: "/", want: "rabbitmq-server"}, + {label: "shell wrapper names the script", cmd: "/bin/sh /opt/services/payments/run-server.sh --port 9000", cwd: "/opt/services/payments", want: "payments/run-server.sh"}, + }) } diff --git a/engine/internal/ports/enrich.go b/engine/internal/ports/enrich.go index 0003b80f..d3aba0ff 100644 --- a/engine/internal/ports/enrich.go +++ b/engine/internal/ports/enrich.go @@ -144,13 +144,17 @@ func applyContainerStats(pp []ListeningPort, stats map[string]*DockerStatsEntry) } } -// DockerStatsEntry holds pre-fetched per-container stats. +// DockerStatsEntry is a container's resource picture, sampled by the Docker +// watcher before rows are built; a row joins it by container id. State and +// Uptime describe the container, while CPU, memory and the pid count are the +// load it reported. type DockerStatsEntry struct { + State string + Uptime string + CPUPercent float64 MemoryRSS int64 PIDs int - State string - Uptime string } // applyTo copies the container's numbers onto the row that reported it. These diff --git a/engine/internal/ports/enrich_test.go b/engine/internal/ports/enrich_test.go index 3a1488fa..b3d47359 100644 --- a/engine/internal/ports/enrich_test.go +++ b/engine/internal/ports/enrich_test.go @@ -1,148 +1,134 @@ package ports import ( + "maps" "os" "runtime" "testing" "time" ) +// The Unix process table reaches the engine as one `ps` call, so these tests +// describe what the parser has to survive: a command line full of spaces, rows +// that are not processes, and a start time printed in the host's zone without +// saying which zone that is. + func TestParseStartTime(t *testing.T) { - if got := parseStartTime("Wed Mar 18 10:30:00 2026"); got != "2026-03-18T10:30:00Z" { - // ps prints local time; compare only the date/time prefix. - if len(got) < 19 || got[:19] != "2026-03-18T10:30:00" { - t.Fatalf("parseStartTime(lstart) = %q", got) - } + // lstart carries no zone, so only the date and time prefix is comparable. + if got := parseStartTime("Tue Nov 3 08:15:00 2026"); len(got) < 19 || got[:19] != "2026-11-03T08:15:00" { + t.Fatalf("lstart became %q", got) } - if got := parseStartTime("2026-09-02T09:12:41+02:00"); got != "2026-09-02T09:12:41+02:00" { - t.Fatalf("parseStartTime(rfc3339) = %q", got) + if got := parseStartTime("2026-10-01T12:00:00Z"); got != "2026-10-01T12:00:00Z" { + t.Fatalf("an RFC3339 timestamp became %q", got) } - if got := parseStartTime("nonsense"); got != "" { - t.Fatalf("parseStartTime(garbage) = %q", got) + if got := parseStartTime("not a time"); got != "" { + t.Fatalf("unparseable input became %q, want empty", got) } } -// TestParseProcessTable covers the cached always-on table that carries the -// command line, parent and process start time in one ps call. The command may -// contain any whitespace, so it is taken as the remainder of the line, not as -// fields. -func TestParseProcessTable(t *testing.T) { - out := ` 1234 1 Wed Mar 18 10:30:00 2026 /usr/bin/node server.js --port 3000 - 567 42 Wed Mar 18 09:00:01 2026 /Applications/Some App.app/Contents/MacOS/Some App - bad line - 99 1 Wed Mar 18 09:00:01 2026 +func TestParseProcessTableKeepsCommandsWhole(t *testing.T) { + out := ` 4321 1 Tue Nov 3 08:15:00 2026 /usr/bin/python3 worker.py --queue default + 87 4321 Tue Nov 3 08:16:02 2026 /Applications/Example App.app/Contents/MacOS/Example App + this line is not a process row + 90 1 Tue Nov 3 08:17:00 2026 ` got := parseProcessTable(out) if len(got) != 2 { t.Fatalf("parseProcessTable returned %d rows, want 2: %+v", len(got), got) } - if want := "/usr/bin/node server.js --port 3000"; got[1234].command != want { - t.Errorf("command = %q, want %q", got[1234].command, want) + if want := "/usr/bin/python3 worker.py --queue default"; got[4321].command != want { + t.Errorf("command = %q, want %q", got[4321].command, want) } - if got[1234].ppid != 1 { - t.Errorf("ppid = %d, want 1", got[1234].ppid) + if got[4321].ppid != 1 { + t.Errorf("ppid = %d, want 1", got[4321].ppid) } - if got[1234].startedAt == "" { - t.Errorf("startedAt = %q, want the raw lstart", got[1234].startedAt) + if got[4321].startedAt == "" { + t.Error("the raw lstart was dropped") } - if want := "/Applications/Some App.app/Contents/MacOS/Some App"; got[567].command != want { - t.Errorf("command = %q, want %q", got[567].command, want) + if want := "/Applications/Example App.app/Contents/MacOS/Example App"; got[87].command != want { + t.Errorf("command = %q, want %q", got[87].command, want) } } -// TestEnrichPopulatesStartedAtWithoutStats is the smoke-test regression: -// started_at was null in every state.snapshot row because only EnrichStats -// filled it. Enrich alone must set it, for the process running this test. -func TestEnrichPopulatesStartedAtWithoutStats(t *testing.T) { +func TestEnrichSetsStartedAtWithoutStats(t *testing.T) { if runtime.GOOS == "windows" { - t.Skip("the ps table this exercises is the Unix path") + t.Skip("the ps table this exercises belongs to the Unix path") } - pp := []ListeningPort{{Port: 1, PID: os.Getpid(), Process: "option-berth.test"}} - Enrich(pp) // no EnrichStats: started_at is not gated by --stats + rows := []ListeningPort{{Port: 1, PID: os.Getpid(), Process: "option-berth.test"}} + Enrich(rows) // EnrichStats is not called: started_at must not depend on stats - if pp[0].StartedAt == "" { + if rows[0].StartedAt == "" { t.Fatal("Enrich left started_at empty for the current process") } - started, err := time.Parse(time.RFC3339, pp[0].StartedAt) + started, err := time.Parse(time.RFC3339, rows[0].StartedAt) if err != nil { - t.Fatalf("started_at %q is not RFC3339: %v", pp[0].StartedAt, err) + t.Fatalf("started_at %q is not RFC3339: %v", rows[0].StartedAt, err) } if age := time.Since(started); age < 0 || age > 24*time.Hour { - t.Errorf("started_at %s is %s away from now; want a plausible start time", pp[0].StartedAt, age) + t.Errorf("started_at %s is %s away from now; want a plausible start time", rows[0].StartedAt, age) } - if pp[0].Uptime != "" || pp[0].CPUPercent != 0 { - t.Errorf("Enrich collected stats it was not asked for: uptime=%q cpu=%v", pp[0].Uptime, pp[0].CPUPercent) + if rows[0].Uptime != "" || rows[0].CPUPercent != 0 { + t.Errorf("Enrich collected stats it was not asked for: uptime=%q cpu=%v", rows[0].Uptime, rows[0].CPUPercent) } } -// TestUptimeIsLocalTimeNotUTC pins the fix for a negative `stats.uptime`: `ps` -// prints lstart in local time with no zone, and parsing it as UTC put the start -// instant a whole UTC offset away — so a process that had been up for fifteen -// seconds in a +02:00 zone reported "-7185s" while `started_at`, which already -// parsed in the local zone, was right. -func TestUptimeIsLocalTimeNotUTC(t *testing.T) { - for _, zone := range []string{"Europe/Copenhagen", "America/Los_Angeles", "UTC"} { +func TestUptimeReadsLstartInTheHostZone(t *testing.T) { + for _, zone := range []string{"Europe/Berlin", "America/Denver", "UTC"} { loc, err := time.LoadLocation(zone) if err != nil { t.Skipf("no zone database: %v", err) } t.Run(zone, func(t *testing.T) { - prev := time.Local + previous := time.Local time.Local = loc - t.Cleanup(func() { time.Local = prev }) + t.Cleanup(func() { time.Local = previous }) - started := time.Date(2026, 9, 6, 10, 30, 0, 0, loc) - lstart := started.Format("Mon Jan 2 15:04:05 2006") + start := time.Date(2026, 11, 3, 8, 15, 0, 0, loc) + lstart := start.Format("Mon Jan 2 15:04:05 2006") - if got := computeUptimeAt(lstart, started.Add(15*time.Second)); got != "15s" { - t.Errorf("uptime 15s after the start = %q, want 15s", got) + // Reading lstart as UTC would put the start a whole offset away and + // turn a just-started process into a negative uptime. + for _, tc := range []struct { + elapsed time.Duration + want string + }{ + {15 * time.Second, "15s"}, + {90 * time.Minute, "1h30m"}, + } { + if got := computeUptimeAt(lstart, start.Add(tc.elapsed)); got != tc.want { + t.Errorf("uptime %s after the start = %q, want %q", tc.elapsed, got, tc.want) + } } - if got := computeUptimeAt(lstart, started.Add(90*time.Minute)); got != "1h30m" { - t.Errorf("uptime 90m after the start = %q, want 1h30m", got) - } - // started_at and uptime read the same instant, so they agree. - if got := parseStartTime(lstart); got != started.Format(time.RFC3339) { - t.Errorf("started_at = %q, want %q", got, started.Format(time.RFC3339)) + // started_at and uptime must describe the same instant. + if got := parseStartTime(lstart); got != start.Format(time.RFC3339) { + t.Errorf("started_at = %q, want %q", got, start.Format(time.RFC3339)) } }) } } -// TestUptimeNeverGoesNegative: a clock that steps back, or a start time a -// moment in the future, reads as 0s rather than as a negative duration. -func TestUptimeNeverGoesNegative(t *testing.T) { - started := time.Date(2026, 9, 6, 10, 30, 0, 0, time.Local) - lstart := started.Format("Mon Jan 2 15:04:05 2006") - if got := computeUptimeAt(lstart, started.Add(-2*time.Hour)); got != "0s" { +func TestUptimeFloorsAtZeroAndStaysEmptyWhenUnknown(t *testing.T) { + start := time.Date(2026, 11, 3, 8, 15, 0, 0, time.Local) + lstart := start.Format("Mon Jan 2 15:04:05 2006") + if got := computeUptimeAt(lstart, start.Add(-2*time.Hour)); got != "0s" { t.Fatalf("uptime with a backwards clock = %q, want 0s", got) } -} - -// TestUptimeOfAnUnparseableStartIsEmpty, so a client can tell "not known" from -// "just started". -func TestUptimeOfAnUnparseableStartIsEmpty(t *testing.T) { - if got := computeUptimeAt("nonsense", time.Now()); got != "" { - t.Fatalf("uptime of garbage = %q, want empty", got) + if got := computeUptimeAt("not a time", time.Now()); got != "" { + t.Fatalf("uptime of an unparseable start = %q, want empty", got) } } -// TestParseTasklist: the fallback that names a process when the CIM query does -// not. A row it cannot read costs that row only. -func TestParseTasklist(t *testing.T) { - out := "\"System Idle Process\",\"0\",\"Services\",\"0\",\"8 K\"\r\n" + - "\"listener.exe\",\"7276\",\"Console\",\"1\",\"5,120 K\"\r\n" + - "\"not a row\"\r\n" + - "\"svchost.exe\",\"nope\",\"Services\",\"0\",\"9,000 K\"\r\n" + - "\"node.exe\",\"3812\",\"Console\",\"1\",\"42,000 K\"\r\n" +// parseTasklist is the fallback that names a process when the CIM query cannot: +// one unreadable row costs only that row. +func TestParseTasklistSkipsUnreadableRows(t *testing.T) { + out := "\"Worker.exe\",\"4242\",\"Console\",\"1\",\"12,000 K\"\r\n" + + "\"System Idle Process\",\"0\",\"Services\",\"0\",\"8 K\"\r\n" + + "\"broken row\"\r\n" + + "\"BadPid.exe\",\"not-a-pid\",\"Console\",\"1\",\"1 K\"\r\n" got := parseTasklist(out) - want := map[int]string{0: "System Idle Process", 7276: "listener.exe", 3812: "node.exe"} - if len(got) != len(want) { + want := map[int]string{4242: "Worker.exe", 0: "System Idle Process"} + if !maps.Equal(got, want) { t.Fatalf("parseTasklist = %v, want %v", got, want) } - for pid, name := range want { - if got[pid] != name { - t.Errorf("pid %d = %q, want %q", pid, got[pid], name) - } - } } diff --git a/engine/internal/ports/sample.go b/engine/internal/ports/sample.go index d5af7250..5eef1015 100644 --- a/engine/internal/ports/sample.go +++ b/engine/internal/ports/sample.go @@ -13,25 +13,31 @@ import ( // socket. It is what the daemon's stats-only tick reads for the pids a // snapshot already names: no port scan behind it, no attribution, no health. // +// The fields answer three different questions, so they are grouped that way: +// when the process started, what it costs, and what state it is in. +// // ThreadCount is 0 when the platform could not report it in the same call. -// macOS is the case that matters: counting threads needs an additional `ps -M` +// macOS is the case that matters: counting threads needs a separate `ps -M` // listing, so it stays on the scan tick and a sample carries 0, which callers // read as "unchanged" rather than as "no threads". type ProcSample struct { + // StartTime is raw, as ps printed it; StartedAt is RFC3339 and empty when + // the start time did not parse. + StartTime string + StartedAt string + CPUPercent float64 MemoryRSS int64 ThreadCount int - StartTime string // raw, as ps printed it - Uptime string - State string - StartedAt string // RFC3339, "" when the start time did not parse + + State string + Uptime string } -// SampleProcStats reads cpu, memory, state and uptime for the given pids in a -// single `ps` call (one PowerShell call on Windows). A pid the OS no longer -// knows about is simply absent from the result: a process that exited between -// the scan that listed it and this sample must not report zeroes that read -// like facts. +// SampleProcStats samples cpu, memory, state and uptime for pids in one batch: +// a single `ps` call on Unix and one PowerShell call on Windows. A pid the OS +// no longer knows is left out rather than reported as zero — a process that +// exited between the listing and this sample must not look idle. func SampleProcStats(pids []int) map[int]ProcSample { return SampleProcStatsContext(context.Background(), pids) } @@ -160,11 +166,8 @@ func (s ProcSample) Apply(p *ListeningPort) { if !s.MatchesStart(p.StartedAt) { return } - p.CPUPercent = s.CPUPercent - p.MemoryRSS = s.MemoryRSS - p.State = s.State - p.Uptime = s.Uptime - p.StartTime = s.StartTime + p.CPUPercent, p.MemoryRSS, p.State = s.CPUPercent, s.MemoryRSS, s.State + p.Uptime, p.StartTime = s.Uptime, s.StartTime if s.ThreadCount > 0 { p.ThreadCount = s.ThreadCount }