Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions internal/platform/export_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package platform

// The lsof fallback, for tests that fake the lsof binary.
var (
LsofExternalPeers = lsofExternalPeers
LsofResolvedPeers = lsofResolvedPeers
)
9 changes: 3 additions & 6 deletions internal/platform/footprint_darwin.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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[:])
}
Expand Down
15 changes: 12 additions & 3 deletions internal/platform/peers.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,20 @@ 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 {
if pid > 0 {
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)
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
104 changes: 89 additions & 15 deletions internal/platform/platform_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"

"github.com/labstack/driga/internal/model"
Expand Down Expand Up @@ -188,10 +190,89 @@ 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.
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. A tree pid that cannot be read counts as connected.
func (darwinPlatform) ExternalPeers(ctx context.Context, pids []int32) (map[int32]model.PeerState, error) {
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 err != nil {
return nil, err
}
return scan.classify(pids, false), nil
}

// 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) {
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
}
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)
}
}
}
result := classifySockets(scan.sockets, pids, all, resolved)
for _, pid := range scan.unreadable {
result[pid] = model.PeerConnected
}
return result
}

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")
Expand All @@ -201,11 +282,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")
Expand All @@ -221,13 +301,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
}
Expand Down
28 changes: 8 additions & 20 deletions internal/platform/platform_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading