Files
proxypin/lib/network/util/process_info_macos.dart
testercengdong d69b95dbbc fix(macos,process_info): tighten error handling and add endian assert
- Guard against `getProcess` returning null in the macOS FFI path
  (`proc_pidpath` can fail with EPERM or for an already-exited PID).
  Stop unwrapping with `!` and only populate the ProcessInfoCache on
  success; the previous code threw NullThrownError there, which the
  outer try/catch silently swallowed and prevented caching of valid
  later results.
- Add a 5-second negative cache for ports whose owner can't be
  resolved (typical for short-lived clients that exited before we
  scanned). Without it every such request triggered a full PID list
  rescan, defeating the point of the pid cache for the failure path.
- Add a debug-only assert that the host is little-endian. The libproc
  parsing reads `(int)htons(port)` by treating the low two bytes as a
  big-endian uint16, which requires a little-endian host. All shipping
  macOS hardware satisfies this; the assert fails loudly if the
  assumption is ever broken (stripped from release builds).

Re-verified on macOS 26.4: 500 concurrent curl through the proxy,
single ProxyPin process throughout, 9099 freed immediately on exit.
2026-05-12 10:16:42 +08:00

193 lines
8.4 KiB
Dart

/*
* Copyright 2026 Hongen Wang All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
/// macOS libproc FFI bindings used to look up the local process that owns a
/// TCP socket and the executable path of a PID, without spawning child
/// processes.
///
/// Previously this functionality was implemented via
/// `Process.run('bash', ['-c', 'lsof -nP -iTCP:<port> ...'])` and
/// `Process.run('bash', ['-c', 'ps -p <pid> -o comm='])`, which were invoked
/// from the proxy request handling hot path. On macOS, Dart's `Process.run`
/// goes through `fork() + execvp()`; in a multi-threaded Dart VM, mutexes
/// held by other threads at fork time are cloned into the child with no
/// owner, so the child can deadlock before exec on `malloc`, libdispatch,
/// or Objective-C runtime locks. Such children stay alive forever, inherit
/// every fd of the parent (including the listening proxy socket), and pin
/// the bound port even after the parent exits. See issue #763 for repro
/// and full root-cause analysis.
///
/// libproc syscalls are pure system calls with no process spawning, so they
/// avoid the fork problem entirely and are about an order of magnitude
/// faster than spawning `lsof` per request.
///
/// Field offsets within `struct socket_fdinfo` were extracted via a small
/// C probe against `<sys/proc_info.h>` on macOS SDK 14+. The struct has
/// been ABI-stable since macOS 10.7; defensive checks verify the returned
/// size before reading offsets so a hypothetical layout change in a future
/// macOS release surfaces as a null return instead of memory corruption.
// libproc constants (from <libproc.h> / <sys/proc_info.h>)
const int _kProcAllPids = 1;
const int _kProcPidListFds = 1;
const int _kProcPidFdSocketInfo = 3;
const int _kProxFdTypeSocket = 2;
const int _kSockInfoTcp = 2;
const int _kProcPidPathInfoMaxSize = 4096;
// Struct sizes (verified via sizeof() probe)
const int _kSizeofProcFdInfo = 8;
const int _kSizeofSocketFdInfo = 792;
// proc_fdinfo field offsets
const int _kOffProcFd = 0; // int32
const int _kOffProcFdType = 4; // uint32
// socket_fdinfo field offsets
const int _kOffSoiKind = 256; // int32, value == _kSockInfoTcp means TCP
const int _kOffInsiLPort = 268; // int32 (htons(uint16) in low 16 bits, network byte order)
// FFI signatures
typedef _ProcListPidsC = Int32 Function(Uint32, Uint32, Pointer<Void>, Int32);
typedef _ProcListPidsDart = int Function(int, int, Pointer<Void>, int);
typedef _ProcPidInfoC = Int32 Function(Int32, Int32, Uint64, Pointer<Void>, Int32);
typedef _ProcPidInfoDart = int Function(int, int, int, Pointer<Void>, int);
typedef _ProcPidFdInfoC = Int32 Function(Int32, Int32, Int32, Pointer<Void>, Int32);
typedef _ProcPidFdInfoDart = int Function(int, int, int, Pointer<Void>, int);
typedef _ProcPidPathC = Int32 Function(Int32, Pointer<Void>, Uint32);
typedef _ProcPidPathDart = int Function(int, Pointer<Void>, int);
// libproc symbols live in libSystem which is already linked into every
// macOS process, so DynamicLibrary.process() finds them.
final DynamicLibrary _libproc = DynamicLibrary.process();
final _procListPids = _libproc.lookupFunction<_ProcListPidsC, _ProcListPidsDart>('proc_listpids');
final _procPidInfo = _libproc.lookupFunction<_ProcPidInfoC, _ProcPidInfoDart>('proc_pidinfo');
final _procPidFdInfo = _libproc.lookupFunction<_ProcPidFdInfoC, _ProcPidFdInfoDart>('proc_pidfdinfo');
final _procPidPath = _libproc.lookupFunction<_ProcPidPathC, _ProcPidPathDart>('proc_pidpath');
class MacosProcessInfo {
/// Returns the PID that owns a TCP socket whose local port equals
/// [localPort], or null if no such socket is found or the lookup fails.
///
/// Walks all PIDs and their fds via libproc syscalls. No child processes
/// are spawned. Typical cost on a desktop with ~500 processes and ~10k
/// total fds is a few milliseconds.
static int? findPidByLocalTcpPort(int localPort) {
// The insi_lport read below assumes a little-endian host: `(int)htons(port)`
// stores the network-byte-order 16-bit port in the low two bytes of the
// int32 field. All shipping macOS hardware (x86_64 / arm64) is
// little-endian; this assert exists to fail loudly rather than return
// wrong port values if that ever changes. Stripped in release builds.
assert(Endian.host == Endian.little, 'libproc parsing requires little-endian host');
final pidBufSize = _procListPids(_kProcAllPids, 0, nullptr, 0);
if (pidBufSize <= 0) return null;
final pidBuf = calloc<Uint8>(pidBufSize);
final sockBuf = calloc<Uint8>(_kSizeofSocketFdInfo);
// Reuse the same ByteData view across all fds; the underlying native
// buffer is overwritten in place by each proc_pidfdinfo call.
final sockView = ByteData.sublistView(sockBuf.asTypedList(_kSizeofSocketFdInfo));
// If proc_pidfdinfo keeps returning a size smaller than expected, the
// struct layout has changed (e.g. a future macOS bumped the size) and
// continuing the scan can't possibly find anything. Bail out early
// after enough consecutive mismatches to avoid wasting syscalls.
var consecutiveLayoutMismatch = 0;
const layoutMismatchThreshold = 16;
try {
final actual = _procListPids(_kProcAllPids, 0, pidBuf.cast(), pidBufSize);
if (actual <= 0) return null;
final pidView = pidBuf.cast<Int32>().asTypedList(actual ~/ 4);
for (final pid in pidView) {
if (pid <= 0) continue;
if (consecutiveLayoutMismatch >= layoutMismatchThreshold) break;
final fdSize = _procPidInfo(pid, _kProcPidListFds, 0, nullptr, 0);
if (fdSize <= 0) continue;
final fdBuf = calloc<Uint8>(fdSize);
try {
final fdActual = _procPidInfo(pid, _kProcPidListFds, 0, fdBuf.cast(), fdSize);
if (fdActual <= 0) continue;
final fdView = ByteData.sublistView(fdBuf.asTypedList(fdActual));
final fdCount = fdActual ~/ _kSizeofProcFdInfo;
for (int j = 0; j < fdCount; j++) {
final entryOff = j * _kSizeofProcFdInfo;
final fdType = fdView.getUint32(entryOff + _kOffProcFdType, Endian.host);
if (fdType != _kProxFdTypeSocket) continue;
final fd = fdView.getInt32(entryOff + _kOffProcFd, Endian.host);
final n = _procPidFdInfo(pid, fd, _kProcPidFdSocketInfo, sockBuf.cast(), _kSizeofSocketFdInfo);
if (n < _kSizeofSocketFdInfo) {
consecutiveLayoutMismatch++;
continue;
}
consecutiveLayoutMismatch = 0;
final soiKind = sockView.getInt32(_kOffSoiKind, Endian.host);
if (soiKind != _kSockInfoTcp) continue;
// insi_lport is stored as `(int)htons(port)` per xnu's
// fill_socketinfo(): the 16-bit network-byte-order port number
// sits in the low two bytes of the int32 field with the upper
// two bytes zero. On little-endian (all current macOS hosts)
// those low bytes are at offset 0/1 of the int32, and reading
// them as a big-endian uint16 yields the host port directly.
final port = sockView.getUint16(_kOffInsiLPort, Endian.big);
if (port == localPort) {
return pid;
}
}
} finally {
calloc.free(fdBuf);
}
}
return null;
} finally {
calloc.free(pidBuf);
calloc.free(sockBuf);
}
}
/// Returns the absolute executable path of [pid], or null if not
/// accessible (e.g. permission denied, process gone).
static String? getProcessPath(int pid) {
final buf = calloc<Uint8>(_kProcPidPathInfoMaxSize);
try {
final ret = _procPidPath(pid, buf.cast(), _kProcPidPathInfoMaxSize);
if (ret <= 0) return null;
// proc_pidpath returns the byte length excluding the trailing NUL.
return buf.cast<Utf8>().toDartString(length: ret);
} finally {
calloc.free(buf);
}
}
}