mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-06-01 17:15:48 +08:00
- 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.
193 lines
8.4 KiB
Dart
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);
|
|
}
|
|
}
|
|
}
|