From 7d12b4138b69dd479fdda944c5d67cf419c8e9a4 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Fri, 30 Oct 2026 23:40:03 -0700 Subject: [PATCH 1/2] perf(platform): read macOS sockets through proc_info instead of lsof The macOS socket check started lsof once per socket class and 200-pid batch, plus one unrestricted lsof pass to resolve a listener's clients, and parsed its text output. That text caused two bugs this month (the socket address in field d, not D; accepted connections shown only by their bound path) and timed out on loaded machines. The check now calls the proc_info syscall that libproc and lsof use: PROC_PIDLISTFDS lists a process's descriptors and PROC_PIDFDSOCKETINFO returns struct socket_fdinfo, decoded at the SDK's offsets (identical for arm64 and x86_64). soi_pcb is a Unix socket's own address and unsi_conn_pcb its peer, so an accepted connection now names its client directly. Records feed the existing classifier unchanged. - The all-process scan of resolved checks takes about 7 ms instead of 126 ms for lsof. - A process that exited is skipped; another user's process (EPERM with a different owner) is expected and makes its peers system services; any other unreadable process of this user fails the check closed. - A socket record of unexpected size means the kernel layout changed: the check logs socket_layout_fallback once and uses lsof. --- docs/usage.md | 10 +- internal/platform/export_darwin_test.go | 7 + internal/platform/peers.go | 15 +- internal/platform/platform_darwin.go | 89 ++++++-- internal/platform/platform_darwin_test.go | 28 +-- internal/platform/sockets_darwin.go | 259 ++++++++++++++++++++++ internal/platform/sockets_darwin_test.go | 213 ++++++++++++++++++ 7 files changed, 580 insertions(+), 41 deletions(-) create mode 100644 internal/platform/export_darwin_test.go create mode 100644 internal/platform/sockets_darwin.go create mode 100644 internal/platform/sockets_darwin_test.go diff --git a/docs/usage.md b/docs/usage.md index 79fa5fd..d5c7f5d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -118,9 +118,13 @@ A connection to another of your processes, any network connection, and any other process's listener, such as a dev server a child started, still keep the tree. The Codex plugin's `app-server-broker.mjs` and `claude mcp serve` set it, because their clients start a replacement or use stdio. A peer that has already -closed does not count. On macOS, telling a listener's clients from system -services takes one extra `lsof` pass over all your processes, run only for -trees with such a helper; if it fails, every socket counts as in use. A process +closed does not count. On macOS, Driga reads sockets through the kernel's +`proc_info` interface, the one `lsof` uses, without starting `lsof`. Telling a +listener's clients from system services takes one extra scan of all your +processes, run only for trees with such a helper; if any of your processes +cannot be read, every socket counts as in use. If a macOS update changes the +kernel's socket record, Driga logs `socket_layout_fallback` and uses `lsof` +instead. A process that runs from a running app's bundle protects its tree, unless an agent helper started it to borrow the bundle's runtime. diff --git a/internal/platform/export_darwin_test.go b/internal/platform/export_darwin_test.go new file mode 100644 index 0000000..5eb39ce --- /dev/null +++ b/internal/platform/export_darwin_test.go @@ -0,0 +1,7 @@ +package platform + +// The lsof fallback, for tests that fake the lsof binary. +var ( + LsofExternalPeers = lsofExternalPeers + LsofResolvedPeers = lsofResolvedPeers +) diff --git a/internal/platform/peers.go b/internal/platform/peers.go index 5df9e67..7ad8784 100644 --- a/internal/platform/peers.go +++ b/internal/platform/peers.go @@ -21,6 +21,13 @@ func ParseLsofPeers(out []byte, pids []int32) map[int32]model.PeerState { // listed process owns belongs to another user, such as a system service, and // reports PeerSystem. Without all, every unresolved peer stays connected. func ParseLsofPeersAll(out []byte, pids []int32, all []byte) map[int32]model.PeerState { + return classifySockets(parseLsofSockets(out), pids, parseLsofSockets(all), all != nil) +} + +// classifySockets is ParseLsofPeersAll over parsed records, from lsof or +// libproc. sockets holds the tree's records and may hold other processes'; +// with resolved set, all holds every visible process's Unix sockets. +func classifySockets(sockets []lsofSocket, pids []int32, all []lsofSocket, resolved bool) map[int32]model.PeerState { wanted := make(map[int32]bool, len(pids)) result := make(map[int32]model.PeerState, len(pids)) for _, pid := range pids { @@ -28,7 +35,6 @@ func ParseLsofPeersAll(out []byte, pids []int32, all []byte) map[int32]model.Pee wanted[pid], result[pid] = true, model.PeerNone } } - sockets := parseLsofSockets(out) // Collect endpoint ownership before resolving peers: the peer's process // record may occur after the connecting process in lsof's output. owners := make(map[string][]int32) @@ -38,9 +44,9 @@ func ParseLsofPeersAll(out []byte, pids []int32, all []byte) map[int32]model.Pee } } var visible map[string]bool - if all != nil { + if resolved { visible = make(map[string]bool) - for _, s := range parseLsofSockets(all) { + for _, s := range all { for _, endpoint := range s.endpoints() { visible[endpoint] = true } @@ -109,6 +115,9 @@ func socketEndpoint(name string) string { return name } +// lsofSocket is one socket as lsof -FpcdnDtT describes it: device is a Unix +// socket's kernel address, and name its bound path and "->" peer, or an +// Internet socket's endpoints. type lsofSocket struct { pid int32 device, kind, name, state string diff --git a/internal/platform/platform_darwin.go b/internal/platform/platform_darwin.go index 025e004..4246448 100644 --- a/internal/platform/platform_darwin.go +++ b/internal/platform/platform_darwin.go @@ -5,10 +5,12 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "os/exec" "strconv" "strings" + "sync" "time" "github.com/labstack/driga/internal/model" @@ -188,10 +190,74 @@ const ( lsofAllDeadline = 10 * time.Second ) -// ExternalPeers checks the tree's own Unix and Internet sockets within five -// seconds per batch of 200 pids, so large trees are not cut off on a loaded -// machine. +// ExternalPeers checks the tree's own Unix and Internet sockets through the +// proc_info syscall, falling back to lsof if the kernel's socket layout +// changed. func (darwinPlatform) ExternalPeers(ctx context.Context, pids []int32) (map[int32]model.PeerState, error) { + sockets, err := treeSockets(ctx, pids) + if err == nil { + return classifySockets(sockets, pids, nil, false), nil + } + if !errors.Is(err, errSocketLayout) { + return nil, err + } + warnSocketLayout(err) + return lsofExternalPeers(ctx, pids) +} + +// ResolvedPeers takes the tree's Unix sockets from one proc_info scan of all +// of this user's processes, so a listener's clients and the owners of outside +// peers come from the same snapshot as the tree. If any of this user's +// processes cannot be read, every pid reports PeerConnected. +func (darwinPlatform) ResolvedPeers(ctx context.Context, pids []int32) (map[int32]model.PeerState, error) { + tree, err := treeSockets(ctx, pids) + var all []lsofSocket + complete := false + if err == nil { + all, complete, err = allUnixSockets(ctx) + } + if errors.Is(err, errSocketLayout) { + warnSocketLayout(err) + return lsofResolvedPeers(ctx, pids) + } + if err != nil { + return nil, err + } + if !complete { + return allConnected(pids), nil + } + sockets := all + for _, s := range tree { + if s.kind != "unix" { + sockets = append(sockets, s) + } + } + return classifySockets(sockets, pids, all, true), nil +} + +var socketLayoutWarned sync.Once + +func warnSocketLayout(err error) { + socketLayoutWarned.Do(func() { + slog.Warn("socket check falls back to lsof", "event", "socket_layout_fallback", "err", err.Error()) + }) +} + +// allConnected fails a socket check closed: every pid counts as in use. +func allConnected(pids []int32) map[int32]model.PeerState { + result := make(map[int32]model.PeerState, len(pids)) + for _, pid := range pids { + if pid > 0 { + result[pid] = model.PeerConnected + } + } + return result +} + +// lsofExternalPeers checks the tree's own Unix and Internet sockets within +// five seconds per batch of 200 pids, so large trees are not cut off on a +// loaded machine. +func lsofExternalPeers(ctx context.Context, pids []int32) (map[int32]model.PeerState, error) { ctx, cancel := lsofTreeDeadline(ctx, pids) defer cancel() records, err := lsofTree(ctx, pids, "-U", "-i") @@ -201,11 +267,10 @@ func (darwinPlatform) ExternalPeers(ctx context.Context, pids []int32) (map[int3 return ParseLsofPeers(records, pids), nil } -// ResolvedPeers takes the tree's Unix sockets from one lsof pass over all of -// this user's processes, so a listener's clients and the owners of outside -// peers come from the same snapshot as the tree. If that pass fails or is -// incomplete, every pid reports PeerConnected. -func (darwinPlatform) ResolvedPeers(ctx context.Context, pids []int32) (map[int32]model.PeerState, error) { +// lsofResolvedPeers is ResolvedPeers through one lsof pass over all of this +// user's processes. If that pass fails or is incomplete, every pid reports +// PeerConnected. +func lsofResolvedPeers(ctx context.Context, pids []int32) (map[int32]model.PeerState, error) { treeCtx, cancel := lsofTreeDeadline(ctx, pids) defer cancel() inet, err := lsofTree(treeCtx, pids, "-i") @@ -221,13 +286,7 @@ func (darwinPlatform) ResolvedPeers(ctx context.Context, pids []int32) (map[int3 if err != nil || allCtx.Err() != nil { // Exit 1 means lsof skipped something; a missing client must not // read as idle. - result := make(map[int32]model.PeerState, len(pids)) - for _, pid := range pids { - if pid > 0 { - result[pid] = model.PeerConnected - } - } - return result, nil + return allConnected(pids), nil } return ParseLsofPeersAll(append(inet, all...), pids, all), nil } diff --git a/internal/platform/platform_darwin_test.go b/internal/platform/platform_darwin_test.go index 085a98e..a1ead35 100644 --- a/internal/platform/platform_darwin_test.go +++ b/internal/platform/platform_darwin_test.go @@ -126,31 +126,23 @@ func TestCwdsCanceled(t *testing.T) { } } -func TestExternalPeers(t *testing.T) { +func TestLsofExternalPeers(t *testing.T) { fakeCommand(t, "lsof", `[ "$#" -eq 8 ] && [ "$1" = -a ] && [ "$3" = -n ] && [ "$4" = -P ] && [ "$5" = -F ] && [ "$6" = pcdnDtT ] && [ "$7" = -p ] && [ "$8" = 10,11 ] || exit 2 [ "$2" = -i ] && exit 1 [ "$2" = -U ] || exit 2 printf 'p10\nf3\ntunix\nD0xa\nn->0xb\np11\nf3\ntunix\nD0xb\nn->0xa\n' exit 1 `) - prober, ok := platform.New().(platform.SocketProber) - if !ok { - t.Fatal("Darwin platform does not implement SocketProber") - } - got, err := prober.ExternalPeers(context.Background(), []int32{10, 11}) + got, err := platform.LsofExternalPeers(context.Background(), []int32{10, 11}) if err != nil || len(got) != 2 || got[10] != model.PeerNone || got[11] != model.PeerNone { t.Fatalf("peers = %v, %v", got, err) } } -func TestExternalPeersTimeout(t *testing.T) { +func TestLsofExternalPeersTimeout(t *testing.T) { fakeCommand(t, "lsof", "exec /bin/sleep 7\n") - prober, ok := platform.New().(platform.SocketProber) - if !ok { - t.Fatal("Darwin platform does not implement SocketProber") - } start := time.Now() - _, err := prober.ExternalPeers(context.Background(), []int32{10}) + _, err := platform.LsofExternalPeers(context.Background(), []int32{10}) if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > 6*time.Second { t.Fatalf("error = %v after %s", err, time.Since(start)) } @@ -293,18 +285,14 @@ func TestCwdsAllOwnProcessesQuickly(t *testing.T) { } } -// If the unrestricted pass is incomplete, a client may be missing, so every -// pid reports connected rather than idle. -func TestResolvedPeersFailsClosed(t *testing.T) { +// If the lsof fallback's unrestricted pass is incomplete, a client may be +// missing, so every pid reports connected rather than idle. +func TestLsofResolvedPeersFailsClosed(t *testing.T) { fakeCommand(t, "lsof", `[ "$2" = -i ] && exit 1 [ "$1" = -U ] && { printf 'p10\nf3\ntunix\nd0xa\nn/private/tmp/broker.sock\n'; exit 1; } exit 2 `) - resolver, ok := platform.New().(platform.PeerResolver) - if !ok { - t.Fatal("Darwin platform does not implement PeerResolver") - } - got, err := resolver.ResolvedPeers(context.Background(), []int32{10, 11}) + got, err := platform.LsofResolvedPeers(context.Background(), []int32{10, 11}) if err != nil || got[10] != model.PeerConnected || got[11] != model.PeerConnected { t.Fatalf("peers = %v, %v", got, err) } diff --git a/internal/platform/sockets_darwin.go b/internal/platform/sockets_darwin.go new file mode 100644 index 0000000..c60bddc --- /dev/null +++ b/internal/platform/sockets_darwin.go @@ -0,0 +1,259 @@ +package platform + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "net/netip" + "os" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" +) + +// The proc_info syscall that libproc's proc_listallpids, proc_pidinfo and +// proc_pidfdinfo wrap, and the layout of struct socket_fdinfo from the macOS +// SDK's sys/proc_info.h. Offsets match for arm64 and x86_64. +// https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/proc_info.h +const ( + procInfoListPids = 1 // PROC_INFO_CALL_LISTPIDS + procInfoPidInfo = 2 // PROC_INFO_CALL_PIDINFO + procInfoPidFdInfo = 3 // PROC_INFO_CALL_PIDFDINFO + + procAllPids = 1 // PROC_ALL_PIDS + procPidListFds = 1 // PROC_PIDLISTFDS + procPidFdSocketInfo = 3 // PROC_PIDFDSOCKETINFO + proxFdTypeSocket = 2 // PROX_FDTYPE_SOCKET + + sizeofProcFdInfo = 8 // struct proc_fdinfo + sizeofSocketFdInfo = 792 // struct socket_fdinfo + maxProcFds = 1 << 20 + + offSocketPcb = 168 // psi.soi_pcb: lsof's d for a Unix socket + offSocketKind = 256 // psi.soi_kind + offSocketProt = 264 // psi.soi_proto union + offUnConnPcb = offSocketProt + 8 // unsi_conn_pcb: lsof's ->0x peer + offUnAddr = offSocketProt + 16 // unsi_addr: sockaddr_un in a 255-byte union + offInFport = offSocketProt + 0 // insi_fport, network order in an int + offInLport = offSocketProt + 4 // insi_lport + offInVflag = offSocketProt + 24 // insi_vflag: INI_IPV4 = 1 + offInFaddr = offSocketProt + 32 // insi_faddr + offInLaddr = offSocketProt + 48 // insi_laddr + offTCPState = offSocketProt + 80 // tcpsi_state + socketKindIn = 1 // SOCKINFO_IN + socketKindTCP = 2 // SOCKINFO_TCP + socketKindUn = 3 // SOCKINFO_UN +) + +// errSocketLayout means the kernel's socket_fdinfo no longer matches the +// layout above; callers fall back to lsof. +var errSocketLayout = errors.New("socket_fdinfo layout changed") + +var tcpStateNames = [...]string{"CLOSED", "LISTEN", "SYN_SENT", "SYN_RECEIVED", "ESTABLISHED", "CLOSE_WAIT", "FIN_WAIT_1", "CLOSING", "LAST_ACK", "FIN_WAIT_2", "TIME_WAIT"} + +func procInfo(call, pid, flavor int, arg uint64, buf []byte) (int, error) { + var p unsafe.Pointer + if len(buf) > 0 { + p = unsafe.Pointer(&buf[0]) + } + n, _, errno := syscall.Syscall6(unix.SYS_PROC_INFO, uintptr(call), uintptr(pid), uintptr(flavor), uintptr(arg), uintptr(p), uintptr(len(buf))) + if errno != 0 { + return 0, errno + } + return int(n), nil +} + +// procAllPIDs is proc_listallpids. +func procAllPIDs() ([]int32, error) { + n, err := procInfo(procInfoListPids, procAllPids, 0, 0, nil) + if err != nil { + return nil, err + } + // Leave room for processes started between the two calls. + buf := make([]byte, n+256*4) + n, err = procInfo(procInfoListPids, procAllPids, 0, 0, buf) + if err != nil { + return nil, err + } + var pids []int32 + for i := 0; i+4 <= n; i += 4 { + if pid := int32(binary.LittleEndian.Uint32(buf[i:])); pid > 0 { + pids = append(pids, pid) + } + } + return pids, nil +} + +// procSockets returns pid's sockets as lsof-equivalent records. ESRCH means +// the process exited and EPERM that it belongs to another user; both are +// returned for the caller to classify. +func procSockets(pid int32) ([]lsofSocket, error) { + var fds []byte + for size := 64 * sizeofProcFdInfo; ; size *= 2 { + if size > maxProcFds*sizeofProcFdInfo { + return nil, fmt.Errorf("pid %d: too many open files", pid) + } + fds = make([]byte, size) + n, err := procInfo(procInfoPidInfo, int(pid), procPidListFds, 0, fds) + if err != nil { + return nil, err + } + // A full buffer may have been truncated. + if n < size { + fds = fds[:n] + break + } + } + var sockets []lsofSocket + info := make([]byte, sizeofSocketFdInfo) + for i := 0; i+sizeofProcFdInfo <= len(fds); i += sizeofProcFdInfo { + if binary.LittleEndian.Uint32(fds[i+4:]) != proxFdTypeSocket { + continue + } + fd := binary.LittleEndian.Uint32(fds[i:]) + n, err := procInfo(procInfoPidFdInfo, int(pid), procPidFdSocketInfo, uint64(fd), info) + if errors.Is(err, syscall.EBADF) { + // Closed between listing and reading. + continue + } + if err != nil { + return nil, err + } + if n != sizeofSocketFdInfo { + return nil, fmt.Errorf("pid %d fd %d: %d bytes: %w", pid, fd, n, errSocketLayout) + } + if s, ok := decodeSocketFdInfo(pid, info); ok { + sockets = append(sockets, s) + } + } + return sockets, nil +} + +// decodeSocketFdInfo converts one struct socket_fdinfo into the record lsof +// would print for it: a Unix socket's own and peer kernel addresses and bound +// path, or an Internet socket's endpoints and TCP state. A connection a server +// accepted carries its client's address here, which lsof hides. +func decodeSocketFdInfo(pid int32, b []byte) (lsofSocket, bool) { + s := lsofSocket{pid: pid} + u32 := func(o int) uint32 { return binary.LittleEndian.Uint32(b[o:]) } + u64 := func(o int) uint64 { return binary.LittleEndian.Uint64(b[o:]) } + switch u32(offSocketKind) { + case socketKindUn: + s.kind, s.device = "unix", fmt.Sprintf("%#x", u64(offSocketPcb)) + s.name = sockaddrUnPath(b[offUnAddr : offUnAddr+255]) + if peer := u64(offUnConnPcb); peer != 0 { + s.name += fmt.Sprintf("->%#x", peer) + } + case socketKindIn, socketKindTCP: + v4 := b[offInVflag]&1 != 0 + s.kind = "IPv6" + if v4 { + s.kind = "IPv4" + } + port := func(o int) uint16 { p := uint16(u32(o)); return p>>8 | p<<8 } + s.name = netip.AddrPortFrom(inAddr(b[offInLaddr:], v4), port(offInLport)).String() + if remote := port(offInFport); remote != 0 { + s.name += "->" + netip.AddrPortFrom(inAddr(b[offInFaddr:], v4), remote).String() + } + if u32(offSocketKind) == socketKindTCP { + if state := u32(offTCPState); int(state) < len(tcpStateNames) { + s.state = tcpStateNames[state] + } + } + default: + return s, false + } + return s, true +} + +// sockaddrUnPath reads sun_path from struct sockaddr_un (sun_len, sun_family, +// sun_path). +func sockaddrUnPath(b []byte) string { + n := int(b[0]) + if n <= 2 { + return "" + } + path := b[2:min(n, len(b))] + for i, c := range path { + if c == 0 { + return string(path[:i]) + } + } + return string(path) +} + +// inAddr reads struct in4in6_addr (IPv4 in its last four bytes) or in6_addr. +func inAddr(b []byte, v4 bool) netip.Addr { + if v4 { + return netip.AddrFrom4([4]byte(b[12:16])) + } + return netip.AddrFrom16([16]byte(b[:16])).Unmap() +} + +// treeSockets reads the sockets of pids. A pid that exited reports nothing; +// any other failure is returned. +func treeSockets(ctx context.Context, pids []int32) ([]lsofSocket, error) { + var sockets []lsofSocket + for _, pid := range pids { + if err := ctx.Err(); err != nil { + return nil, err + } + if pid <= 0 { + continue + } + s, err := procSockets(pid) + if errors.Is(err, syscall.ESRCH) { + continue + } + if err != nil { + return nil, fmt.Errorf("socket check pid %d: %w", pid, err) + } + sockets = append(sockets, s...) + } + return sockets, nil +} + +// allUnixSockets reads every visible process's Unix sockets. complete is +// false when a process of this user could not be read, so a client may be +// missing; another user's processes are expected to be unreadable. +func allUnixSockets(ctx context.Context) (sockets []lsofSocket, complete bool, err error) { + pids, err := procAllPIDs() + if err != nil { + return nil, false, nil + } + uid := uint32(os.Getuid()) + complete = true + for _, pid := range pids { + if err := ctx.Err(); err != nil { + return nil, false, err + } + s, err := procSockets(pid) + switch { + case err == nil: + for _, socket := range s { + if socket.kind == "unix" { + sockets = append(sockets, socket) + } + } + case errors.Is(err, syscall.ESRCH): + case errors.Is(err, syscall.EPERM) && !sameUser(pid, uid): + case errors.Is(err, errSocketLayout): + return nil, false, err + default: + complete = false + } + } + return sockets, complete, nil +} + +// sameUser reports whether pid runs as uid; an unknown owner counts as the +// same user, so it cannot hide a client. +func sameUser(pid int32, uid uint32) bool { + info, err := unix.SysctlKinfoProc("kern.proc.pid", int(pid)) + if err != nil { + return !errors.Is(err, syscall.ESRCH) + } + return info.Eproc.Ucred.Uid == uid +} diff --git a/internal/platform/sockets_darwin_test.go b/internal/platform/sockets_darwin_test.go new file mode 100644 index 0000000..6193d94 --- /dev/null +++ b/internal/platform/sockets_darwin_test.go @@ -0,0 +1,213 @@ +package platform + +import ( + "context" + "encoding/binary" + "net" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/labstack/driga/internal/model" + "golang.org/x/sys/unix" +) + +// Hand-built struct socket_fdinfo records at the SDK offsets. +func TestDecodeSocketFdInfo(t *testing.T) { + unixRecord := make([]byte, sizeofSocketFdInfo) + binary.LittleEndian.PutUint32(unixRecord[offSocketKind:], socketKindUn) + binary.LittleEndian.PutUint64(unixRecord[offSocketPcb:], 0xe0d058e2c7c8edfd) + binary.LittleEndian.PutUint64(unixRecord[offUnConnPcb:], 0x46e036a0d862d8ac) + path := "/private/tmp/cxc/broker.sock" + unixRecord[offUnAddr] = byte(2 + len(path) + 1) + unixRecord[offUnAddr+1] = syscall.AF_UNIX + copy(unixRecord[offUnAddr+2:], path) + got, ok := decodeSocketFdInfo(7, unixRecord) + if want := (lsofSocket{pid: 7, kind: "unix", device: "0xe0d058e2c7c8edfd", name: path + "->0x46e036a0d862d8ac"}); !ok || got != want { + t.Fatalf("unix = %+v, %v; want %+v", got, ok, want) + } + + tcp := make([]byte, sizeofSocketFdInfo) + binary.LittleEndian.PutUint32(tcp[offSocketKind:], socketKindTCP) + tcp[offInVflag] = 1 + copy(tcp[offInLaddr+12:], []byte{127, 0, 0, 1}) + copy(tcp[offInFaddr+12:], []byte{10, 0, 0, 2}) + binary.BigEndian.PutUint16(tcp[offInLport:], 4000) + binary.BigEndian.PutUint16(tcp[offInFport:], 443) + binary.LittleEndian.PutUint32(tcp[offTCPState:], 5) + got, ok = decodeSocketFdInfo(8, tcp) + if want := (lsofSocket{pid: 8, kind: "IPv4", name: "127.0.0.1:4000->10.0.0.2:443", state: "CLOSE_WAIT"}); !ok || got != want { + t.Fatalf("tcp = %+v, %v; want %+v", got, ok, want) + } + + other := make([]byte, sizeofSocketFdInfo) + binary.LittleEndian.PutUint32(other[offSocketKind:], 9) + if _, ok := decodeSocketFdInfo(9, other); ok { + t.Fatal("unknown socket kind decoded") + } +} + +// A listener with a client in another process, read from the live kernel: the +// accepted connection names its client, and only the resolved check sees that +// the client is outside the tree when the accepted side is gone. +func TestProcSocketsLiveListener(t *testing.T) { + dir, err := os.MkdirTemp("/tmp", "drg") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + path := filepath.Join(dir, "s.sock") + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + accepted := make(chan net.Conn, 1) + go func() { + if c, err := listener.Accept(); err == nil { + accepted <- c + } + }() + self := int32(os.Getpid()) + before, err := procSockets(self) + if err != nil { + t.Fatal(err) + } + var listening *lsofSocket + for i, s := range before { + if s.kind == "unix" && s.name == path { + listening = &before[i] + } + } + if listening == nil { + t.Fatalf("listener %s not found in %+v", path, before) + } + + client := exec.Command("/usr/bin/nc", "-U", path) + stdin, err := client.StdinPipe() + if err != nil { + t.Fatal(err) + } + if err := client.Start(); err != nil { + t.Skipf("nc unavailable: %v", err) + } + t.Cleanup(func() { _ = stdin.Close(); _ = client.Process.Kill(); _ = client.Wait() }) + var conn net.Conn + select { + case conn = <-accepted: + case <-time.After(5 * time.Second): + t.Fatal("client did not connect") + } + t.Cleanup(func() { _ = conn.Close() }) + clientPID := int32(client.Process.Pid) + clientSockets, err := procSockets(clientPID) + if err != nil || len(clientSockets) == 0 { + t.Fatalf("client sockets = %+v, %v", clientSockets, err) + } + clientAddr := clientSockets[0].device + after, err := procSockets(self) + if err != nil { + t.Fatal(err) + } + if !slices.ContainsFunc(after, func(s lsofSocket) bool { return s.name == path+"->"+clientAddr }) { + t.Fatalf("accepted connection does not name client %s: %+v", clientAddr, after) + } + + ctx := context.Background() + p := darwinPlatform{} + if got, err := p.ResolvedPeers(ctx, []int32{self}); err != nil || got[self] != model.PeerConnected { + t.Fatalf("listener with outside client = %v, %v", got, err) + } + if got, err := p.ExternalPeers(ctx, []int32{self}); err != nil || got[self] != model.PeerConnected { + t.Fatalf("strict listener with outside client = %v, %v", got, err) + } + // With the client inside the tree, the connection is internal. + if got, err := p.ResolvedPeers(ctx, []int32{self, clientPID}); err != nil || got[clientPID] != model.PeerNone { + t.Fatalf("client inside tree = %v, %v", got, err) + } +} + +// libproc must list the same Unix sockets, with the same addresses and +// peers, as lsof does for this process. +func TestProcSocketsMatchLsof(t *testing.T) { + a, b, err := socketPair() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = unix.Close(a); _ = unix.Close(b) }) + self := int32(os.Getpid()) + out, err := exec.Command("lsof", "-a", "-U", "-n", "-P", "-F", "pcdnDtT", "-p", strconv.Itoa(int(self))).Output() + if err != nil { + t.Skipf("lsof unavailable: %v", err) + } + fromLsof := map[string]string{} + for _, s := range parseLsofSockets(out) { + // lsof's own process records carry no socket address. + if s.device == "" { + continue + } + _, peer, _ := strings.Cut(s.name, "->") + fromLsof[socketEndpoint(s.device)] = socketEndpoint(peer) + } + sockets, err := procSockets(self) + if err != nil { + t.Fatal(err) + } + fromProc := map[string]string{} + for _, s := range sockets { + if s.kind == "unix" { + _, peer, _ := strings.Cut(s.name, "->") + fromProc[socketEndpoint(s.device)] = socketEndpoint(peer) + } + } + if len(fromProc) < 2 { + t.Fatalf("socket pair missing: %v", fromProc) + } + for addr, peer := range fromLsof { + got, ok := fromProc[addr] + if !ok || (peer != "" && peer != "(none)" && got != peer) { + t.Fatalf("lsof %s->%s; libproc %q (%v); all %v", addr, peer, got, ok, fromProc) + } + } +} + +func socketPair() (int, int, error) { + fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) + if err != nil { + return 0, 0, err + } + return fds[0], fds[1], nil +} + +// An exited process reports no sockets; reading it is not an error. +func TestTreeSocketsSkipsExited(t *testing.T) { + cmd := exec.Command("/usr/bin/true") + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + gone := int32(cmd.Process.Pid) + if got, err := (darwinPlatform{}).ExternalPeers(context.Background(), []int32{gone}); err != nil || got[gone] != model.PeerNone { + t.Fatalf("exited pid = %v, %v", got, err) + } +} + +// Another user's processes are unreadable without root; they must not make +// the scan incomplete. +func TestAllUnixSocketsSkipsOtherUsers(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root reads every process") + } + sockets, complete, err := allUnixSockets(context.Background()) + if err != nil || !complete { + t.Fatalf("scan: %d sockets, complete=%v, %v", len(sockets), complete, err) + } + if !sameUser(int32(os.Getpid()), uint32(os.Getuid())) || sameUser(1, uint32(os.Getuid())) { + t.Fatal("process owner lookup") + } +} From 5bec4d25e7d84223464883db57438624f85d0245 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Fri, 30 Oct 2026 23:49:02 -0700 Subject: [PATCH 2/2] fix(platform): harden the proc_info socket scan Review fixes for the proc_info socket check: - A tree pid that cannot be read (for example a setuid child) now marks only that pid connected instead of failing the check for every tree. - ENOTSOCK and EOPNOTSUPP, like EBADF, mean a descriptor changed between listing and reading, so it is skipped. - ENOMEM from PROC_PIDFDSOCKETINFO means the kernel's struct outgrew the buffer, so it also triggers the lsof fallback. - The all-process scan takes processes and owners from one kern.proc.all snapshot: other users' processes are never asked, which removes the per-pid owner lookup, its race, and pid-list truncation. - Each process is read once per scan. The scan has its own deadline (5 s per 200 tree pids, plus 10 s for all processes); running out fails a resolved check closed. - PROC_PIDLISTFDS is sized first and buffers are reused across pids. - physicalFootprint shares the proc_info call, and compile-time checks keep darwinPlatform a SocketProber and PeerResolver. --- internal/platform/footprint_darwin.go | 9 +- internal/platform/platform_darwin.go | 65 ++++--- internal/platform/sockets_darwin.go | 216 +++++++++++++---------- internal/platform/sockets_darwin_test.go | 67 +++++-- 4 files changed, 218 insertions(+), 139 deletions(-) diff --git a/internal/platform/footprint_darwin.go b/internal/platform/footprint_darwin.go index 4b7a675..8cb9d43 100644 --- a/internal/platform/footprint_darwin.go +++ b/internal/platform/footprint_darwin.go @@ -1,10 +1,7 @@ package platform import ( - "syscall" "unsafe" - - "golang.org/x/sys/unix" ) // proc_pid_rusage's documented libproc wrapper invokes __proc_info with @@ -14,9 +11,9 @@ import ( // are requested; denied/exited processes fall back to the sampled RSS. func physicalFootprint(pid int32) (uint64, error) { var b [176]byte - _, _, errno := syscall.Syscall6(unix.SYS_PROC_INFO, 9, uintptr(pid), 2, 0, uintptr(unsafe.Pointer(&b[0])), 0) - if errno != 0 { - return 0, errno + // The buffer size argument stays 0: rusage takes its size from the flavor. + if _, err := procInfoAt(9, int(pid), 2, 0, unsafe.Pointer(&b[0]), 0); err != nil { + return 0, err } return parseFootprint(b[:]) } diff --git a/internal/platform/platform_darwin.go b/internal/platform/platform_darwin.go index 4246448..98105a4 100644 --- a/internal/platform/platform_darwin.go +++ b/internal/platform/platform_darwin.go @@ -190,49 +190,64 @@ const ( lsofAllDeadline = 10 * time.Second ) +var ( + _ SocketProber = darwinPlatform{} + _ PeerResolver = darwinPlatform{} +) + // ExternalPeers checks the tree's own Unix and Internet sockets through the // proc_info syscall, falling back to lsof if the kernel's socket layout -// changed. +// changed. A tree pid that cannot be read counts as connected. func (darwinPlatform) ExternalPeers(ctx context.Context, pids []int32) (map[int32]model.PeerState, error) { - sockets, err := treeSockets(ctx, pids) - if err == nil { - return classifySockets(sockets, pids, nil, false), nil + scan, err := scanSockets(ctx, pids, false) + if errors.Is(err, errSocketLayout) { + warnSocketLayout(err) + return lsofExternalPeers(ctx, pids) + } + if errors.Is(err, errScanDeadline) { + return nil, fmt.Errorf("socket check: %w", context.DeadlineExceeded) } - if !errors.Is(err, errSocketLayout) { + if err != nil { return nil, err } - warnSocketLayout(err) - return lsofExternalPeers(ctx, pids) + return scan.classify(pids, false), nil } -// ResolvedPeers takes the tree's Unix sockets from one proc_info scan of all -// of this user's processes, so a listener's clients and the owners of outside -// peers come from the same snapshot as the tree. If any of this user's -// processes cannot be read, every pid reports PeerConnected. +// ResolvedPeers takes the tree's and every other process's sockets from one +// proc_info scan, so a listener's clients and the owners of outside peers come +// from one snapshot. If any of this user's processes cannot be read, or the +// scan runs out of time, every pid reports PeerConnected. func (darwinPlatform) ResolvedPeers(ctx context.Context, pids []int32) (map[int32]model.PeerState, error) { - tree, err := treeSockets(ctx, pids) - var all []lsofSocket - complete := false - if err == nil { - all, complete, err = allUnixSockets(ctx) - } + scan, err := scanSockets(ctx, pids, true) if errors.Is(err, errSocketLayout) { warnSocketLayout(err) return lsofResolvedPeers(ctx, pids) } + if errors.Is(err, errScanDeadline) || (err == nil && !scan.complete) { + return allConnected(pids), nil + } if err != nil { return nil, err } - if !complete { - return allConnected(pids), nil - } - sockets := all - for _, s := range tree { - if s.kind != "unix" { - sockets = append(sockets, s) + return scan.classify(pids, true), nil +} + +// classify turns a scan into peer states; with resolved set, its Unix +// sockets are every visible process's. +func (scan socketScan) classify(pids []int32, resolved bool) map[int32]model.PeerState { + var all []lsofSocket + if resolved { + for _, s := range scan.sockets { + if s.kind == "unix" { + all = append(all, s) + } } } - return classifySockets(sockets, pids, all, true), nil + result := classifySockets(scan.sockets, pids, all, resolved) + for _, pid := range scan.unreadable { + result[pid] = model.PeerConnected + } + return result } var socketLayoutWarned sync.Once diff --git a/internal/platform/sockets_darwin.go b/internal/platform/sockets_darwin.go index c60bddc..ec7222d 100644 --- a/internal/platform/sockets_darwin.go +++ b/internal/platform/sockets_darwin.go @@ -8,6 +8,7 @@ import ( "net/netip" "os" "syscall" + "time" "unsafe" "golang.org/x/sys/unix" @@ -18,11 +19,9 @@ import ( // SDK's sys/proc_info.h. Offsets match for arm64 and x86_64. // https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/proc_info.h const ( - procInfoListPids = 1 // PROC_INFO_CALL_LISTPIDS procInfoPidInfo = 2 // PROC_INFO_CALL_PIDINFO procInfoPidFdInfo = 3 // PROC_INFO_CALL_PIDFDINFO - procAllPids = 1 // PROC_ALL_PIDS procPidListFds = 1 // PROC_PIDLISTFDS procPidFdSocketInfo = 3 // PROC_PIDFDSOCKETINFO proxFdTypeSocket = 2 // PROX_FDTYPE_SOCKET @@ -53,82 +52,81 @@ var errSocketLayout = errors.New("socket_fdinfo layout changed") var tcpStateNames = [...]string{"CLOSED", "LISTEN", "SYN_SENT", "SYN_RECEIVED", "ESTABLISHED", "CLOSE_WAIT", "FIN_WAIT_1", "CLOSING", "LAST_ACK", "FIN_WAIT_2", "TIME_WAIT"} +// procInfo calls proc_info with buf as its buffer. func procInfo(call, pid, flavor int, arg uint64, buf []byte) (int, error) { var p unsafe.Pointer if len(buf) > 0 { p = unsafe.Pointer(&buf[0]) } - n, _, errno := syscall.Syscall6(unix.SYS_PROC_INFO, uintptr(call), uintptr(pid), uintptr(flavor), uintptr(arg), uintptr(p), uintptr(len(buf))) + return procInfoAt(call, pid, flavor, arg, p, len(buf)) +} + +// procInfoAt is the raw proc_info syscall. +func procInfoAt(call, pid, flavor int, arg uint64, buf unsafe.Pointer, size int) (int, error) { + n, _, errno := syscall.Syscall6(unix.SYS_PROC_INFO, uintptr(call), uintptr(pid), uintptr(flavor), uintptr(arg), uintptr(buf), uintptr(size)) if errno != 0 { return 0, errno } return int(n), nil } -// procAllPIDs is proc_listallpids. -func procAllPIDs() ([]int32, error) { - n, err := procInfo(procInfoListPids, procAllPids, 0, 0, nil) - if err != nil { - return nil, err - } - // Leave room for processes started between the two calls. - buf := make([]byte, n+256*4) - n, err = procInfo(procInfoListPids, procAllPids, 0, 0, buf) +// socketReader reads processes' sockets, reusing its buffers across them. +type socketReader struct{ fds, info []byte } + +// sockets appends pid's sockets as lsof-equivalent records to out. ESRCH means +// the process exited and EPERM that it cannot be read; both are returned for +// the caller to classify. +func (r *socketReader) sockets(pid int32, out []lsofSocket) ([]lsofSocket, error) { + // With no buffer, PROC_PIDLISTFDS returns the size it needs; leave room + // for descriptors opened before the second call. + need, err := procInfo(procInfoPidInfo, int(pid), procPidListFds, 0, nil) if err != nil { - return nil, err - } - var pids []int32 - for i := 0; i+4 <= n; i += 4 { - if pid := int32(binary.LittleEndian.Uint32(buf[i:])); pid > 0 { - pids = append(pids, pid) - } + return out, err } - return pids, nil -} - -// procSockets returns pid's sockets as lsof-equivalent records. ESRCH means -// the process exited and EPERM that it belongs to another user; both are -// returned for the caller to classify. -func procSockets(pid int32) ([]lsofSocket, error) { var fds []byte - for size := 64 * sizeofProcFdInfo; ; size *= 2 { + for size := need + 32*sizeofProcFdInfo; ; size *= 2 { if size > maxProcFds*sizeofProcFdInfo { - return nil, fmt.Errorf("pid %d: too many open files", pid) + return out, fmt.Errorf("pid %d: too many open files", pid) } - fds = make([]byte, size) - n, err := procInfo(procInfoPidInfo, int(pid), procPidListFds, 0, fds) + if cap(r.fds) < size { + r.fds = make([]byte, size) + } + n, err := procInfo(procInfoPidInfo, int(pid), procPidListFds, 0, r.fds[:size]) if err != nil { - return nil, err + return out, err } // A full buffer may have been truncated. if n < size { - fds = fds[:n] + fds = r.fds[:n] break } } - var sockets []lsofSocket - info := make([]byte, sizeofSocketFdInfo) + if r.info == nil { + r.info = make([]byte, sizeofSocketFdInfo) + } for i := 0; i+sizeofProcFdInfo <= len(fds); i += sizeofProcFdInfo { if binary.LittleEndian.Uint32(fds[i+4:]) != proxFdTypeSocket { continue } fd := binary.LittleEndian.Uint32(fds[i:]) - n, err := procInfo(procInfoPidFdInfo, int(pid), procPidFdSocketInfo, uint64(fd), info) - if errors.Is(err, syscall.EBADF) { - // Closed between listing and reading. + n, err := procInfo(procInfoPidFdInfo, int(pid), procPidFdSocketInfo, uint64(fd), r.info) + switch { + case errors.Is(err, syscall.EBADF), errors.Is(err, syscall.ENOTSOCK), errors.Is(err, syscall.EOPNOTSUPP): + // Closed, or reused for something else, between listing and reading. continue + case errors.Is(err, syscall.ENOMEM): + // The kernel's struct outgrew the buffer. + return out, fmt.Errorf("pid %d fd %d: %w", pid, fd, errSocketLayout) + case err != nil: + return out, err + case n != sizeofSocketFdInfo: + return out, fmt.Errorf("pid %d fd %d: %d bytes: %w", pid, fd, n, errSocketLayout) } - if err != nil { - return nil, err - } - if n != sizeofSocketFdInfo { - return nil, fmt.Errorf("pid %d fd %d: %d bytes: %w", pid, fd, n, errSocketLayout) - } - if s, ok := decodeSocketFdInfo(pid, info); ok { - sockets = append(sockets, s) + if s, ok := decodeSocketFdInfo(pid, r.info); ok { + out = append(out, s) } } - return sockets, nil + return out, nil } // decodeSocketFdInfo converts one struct socket_fdinfo into the record lsof @@ -192,68 +190,94 @@ func inAddr(b []byte, v4 bool) netip.Addr { return netip.AddrFrom16([16]byte(b[:16])).Unmap() } -// treeSockets reads the sockets of pids. A pid that exited reports nothing; -// any other failure is returned. -func treeSockets(ctx context.Context, pids []int32) ([]lsofSocket, error) { - var sockets []lsofSocket +// errScanDeadline means a socket scan ran out of its own time, not the +// caller's. +var errScanDeadline = errors.New("socket scan deadline exceeded") + +// scanDeadline bounds a scan: five seconds per 200 tree pids, plus ten for +// the scan of every process. +func scanDeadline(pids []int32, all bool) time.Duration { + d := time.Duration(max(1, (len(pids)+lsofBatch-1)/lsofBatch)) * lsofBatchDeadline + if all { + d += lsofAllDeadline + } + return d +} + +// socketScan is the result of scanSockets. unreadable lists tree pids whose +// sockets could not be read; complete is false when another process of this +// user could not be read, so a client may be missing. +type socketScan struct { + sockets []lsofSocket + unreadable []int32 + complete bool +} + +// scanSockets reads every socket of the tree pids and, with all set, the Unix +// sockets of every other process this user owns, each process once. A process +// that exited reports nothing. +func scanSockets(ctx context.Context, pids []int32, all bool) (socketScan, error) { + parent := ctx + ctx, cancel := context.WithTimeout(ctx, scanDeadline(pids, all)) + defer cancel() + tree := make(map[int32]bool, len(pids)) + targets := make([]int32, 0, len(pids)) for _, pid := range pids { - if err := ctx.Err(); err != nil { - return nil, err - } - if pid <= 0 { - continue - } - s, err := procSockets(pid) - if errors.Is(err, syscall.ESRCH) { - continue + if pid > 0 && !tree[pid] { + tree[pid] = true + targets = append(targets, pid) } + } + scan := socketScan{complete: true} + if all { + // One snapshot names every process and its owner, so other users' + // processes, unreadable without root, are never asked. + kprocs, err := unix.SysctlKinfoProcSlice("kern.proc.all") if err != nil { - return nil, fmt.Errorf("socket check pid %d: %w", pid, err) + scan.complete = false + } + uid := uint32(os.Getuid()) + for _, k := range kprocs { + if pid := k.Proc.P_pid; pid > 0 && !tree[pid] && k.Eproc.Ucred.Uid == uid { + targets = append(targets, pid) + } } - sockets = append(sockets, s...) - } - return sockets, nil -} - -// allUnixSockets reads every visible process's Unix sockets. complete is -// false when a process of this user could not be read, so a client may be -// missing; another user's processes are expected to be unreadable. -func allUnixSockets(ctx context.Context) (sockets []lsofSocket, complete bool, err error) { - pids, err := procAllPIDs() - if err != nil { - return nil, false, nil } - uid := uint32(os.Getuid()) - complete = true - for _, pid := range pids { + var r socketReader + for _, pid := range targets { if err := ctx.Err(); err != nil { - return nil, false, err + if parent.Err() != nil { + return socketScan{}, parent.Err() + } + return socketScan{}, errScanDeadline } - s, err := procSockets(pid) + start := len(scan.sockets) + var err error + scan.sockets, err = r.sockets(pid, scan.sockets) switch { case err == nil: - for _, socket := range s { - if socket.kind == "unix" { - sockets = append(sockets, socket) - } - } case errors.Is(err, syscall.ESRCH): - case errors.Is(err, syscall.EPERM) && !sameUser(pid, uid): case errors.Is(err, errSocketLayout): - return nil, false, err + return socketScan{}, err + case tree[pid]: + scan.unreadable = append(scan.unreadable, pid) default: - complete = false + scan.complete = false + } + if err != nil { + scan.sockets = scan.sockets[:start] + continue + } + if !tree[pid] { + // Other processes matter only as Unix peers. + kept := scan.sockets[:start] + for _, s := range scan.sockets[start:] { + if s.kind == "unix" { + kept = append(kept, s) + } + } + scan.sockets = kept } } - return sockets, complete, nil -} - -// sameUser reports whether pid runs as uid; an unknown owner counts as the -// same user, so it cannot hide a client. -func sameUser(pid int32, uid uint32) bool { - info, err := unix.SysctlKinfoProc("kern.proc.pid", int(pid)) - if err != nil { - return !errors.Is(err, syscall.ESRCH) - } - return info.Eproc.Ucred.Uid == uid + return scan, nil } diff --git a/internal/platform/sockets_darwin_test.go b/internal/platform/sockets_darwin_test.go index 6193d94..c905fe4 100644 --- a/internal/platform/sockets_darwin_test.go +++ b/internal/platform/sockets_darwin_test.go @@ -75,7 +75,7 @@ func TestProcSocketsLiveListener(t *testing.T) { } }() self := int32(os.Getpid()) - before, err := procSockets(self) + before, err := new(socketReader).sockets(self, nil) if err != nil { t.Fatal(err) } @@ -106,12 +106,12 @@ func TestProcSocketsLiveListener(t *testing.T) { } t.Cleanup(func() { _ = conn.Close() }) clientPID := int32(client.Process.Pid) - clientSockets, err := procSockets(clientPID) + clientSockets, err := new(socketReader).sockets(clientPID, nil) if err != nil || len(clientSockets) == 0 { t.Fatalf("client sockets = %+v, %v", clientSockets, err) } clientAddr := clientSockets[0].device - after, err := procSockets(self) + after, err := new(socketReader).sockets(self, nil) if err != nil { t.Fatal(err) } @@ -155,7 +155,7 @@ func TestProcSocketsMatchLsof(t *testing.T) { _, peer, _ := strings.Cut(s.name, "->") fromLsof[socketEndpoint(s.device)] = socketEndpoint(peer) } - sockets, err := procSockets(self) + sockets, err := new(socketReader).sockets(self, nil) if err != nil { t.Fatal(err) } @@ -197,17 +197,60 @@ func TestTreeSocketsSkipsExited(t *testing.T) { } } -// Another user's processes are unreadable without root; they must not make -// the scan incomplete. -func TestAllUnixSocketsSkipsOtherUsers(t *testing.T) { +// Another user's processes are never read, so they cannot make the scan +// incomplete; this process's sockets are read once, with every kind kept only +// for tree pids. +func TestScanSocketsAllProcesses(t *testing.T) { if os.Getuid() == 0 { t.Skip("root reads every process") } - sockets, complete, err := allUnixSockets(context.Background()) - if err != nil || !complete { - t.Fatalf("scan: %d sockets, complete=%v, %v", len(sockets), complete, err) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + a, b, err := socketPair() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = unix.Close(a); _ = unix.Close(b) }) + self := int32(os.Getpid()) + scan, err := scanSockets(context.Background(), []int32{self}, true) + if err != nil || !scan.complete || len(scan.unreadable) != 0 { + t.Fatalf("scan: %d sockets, complete=%v, unreadable=%v, %v", len(scan.sockets), scan.complete, scan.unreadable, err) + } + if !slices.ContainsFunc(scan.sockets, func(s lsofSocket) bool { return s.pid == self && s.kind == "IPv4" && s.state == "LISTEN" }) { + t.Fatal("tree pid's TCP listener missing") + } + if slices.ContainsFunc(scan.sockets, func(s lsofSocket) bool { return s.pid != self && s.kind != "unix" }) { + t.Fatal("kept a non-Unix socket of another process") + } + if slices.ContainsFunc(scan.sockets, func(s lsofSocket) bool { return s.pid == 1 }) { + t.Fatal("read launchd, another user's process") + } +} + +// A tree pid that cannot be read keeps its tree in use without failing the +// check for every other tree. +func TestUnreadableTreePIDCountsConnected(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root reads every process") + } + self := int32(os.Getpid()) + got, err := (darwinPlatform{}).ExternalPeers(context.Background(), []int32{1, self}) + if err != nil || got[1] != model.PeerConnected { + t.Fatalf("unreadable pid 1 = %v, %v", got, err) + } + if _, ok := got[self]; !ok { + t.Fatalf("readable pid missing: %v", got) + } +} + +func TestScanDeadline(t *testing.T) { + if got := scanDeadline(make([]int32, 401), true); got != 3*lsofBatchDeadline+lsofAllDeadline { + t.Fatalf("deadline = %s", got) } - if !sameUser(int32(os.Getpid()), uint32(os.Getuid())) || sameUser(1, uint32(os.Getuid())) { - t.Fatal("process owner lookup") + if got := scanDeadline(nil, false); got != lsofBatchDeadline { + t.Fatalf("deadline = %s", got) } }