diff --git a/lib/network/util/process_info.dart b/lib/network/util/process_info.dart index ff49431..62d1496 100644 --- a/lib/network/util/process_info.dart +++ b/lib/network/util/process_info.dart @@ -24,6 +24,7 @@ import 'package:proxypin/network/util/socket_address.dart'; import 'package:win32audio/win32audio.dart'; import 'cache.dart'; +import 'process_info_macos.dart'; void main() async { var processInfo = await ProcessInfoUtils.getProcess(512); @@ -84,21 +85,12 @@ class ProcessInfoUtils { } if (Platform.isMacOS) { - var results = - await Process.run('bash', ['-c', 'lsof -nP -iTCP:${socketAddress.port} |grep "${socketAddress.port}->"']); - - if (results.exitCode != 0) { - return null; - } - - var lines = LineSplitter.split(results.stdout); - - for (var line in lines) { - var parts = line.trim().split(RegExp(r'\s+')); - if (parts.length >= 9) { - return int.tryParse(parts[1]); - } - } + // Use libproc syscalls (FFI) instead of spawning `lsof`. Each + // Process.run on macOS goes through fork()+execvp(); under load a + // multi-threaded Dart VM occasionally deadlocks the forked child + // before exec, and the orphaned child keeps the inherited listening + // socket fd alive forever. See issue #763. + return MacosProcessInfo.findPidByLocalTcpPort(socketAddress.port); } return null; } @@ -114,19 +106,12 @@ class ProcessInfoUtils { } if (Platform.isMacOS) { - var results = await Process.run('bash', ['-c', 'ps -p $pid -o pid= -o comm=']); - if (results.exitCode == 0) { - var lines = LineSplitter.split(results.stdout); - for (var line in lines) { - var parts = line.trim().split(RegExp(r'\s+')); - if (parts.length >= 2) { - parts.removeAt(0).trim(); - var path = parts.join(" ").split(".app/")[0]; - String name = path.substring(path.lastIndexOf('/') + 1); - return ProcessInfo(name, name, "$path.app", os: Platform.operatingSystem); - } - } - } + // Use libproc syscalls (FFI) instead of spawning `ps`. See issue #763. + final fullPath = MacosProcessInfo.getProcessPath(pid); + if (fullPath == null) return null; + final path = fullPath.split('.app/')[0]; + final name = path.substring(path.lastIndexOf('/') + 1); + return ProcessInfo(name, name, "$path.app", os: Platform.operatingSystem); } return null; diff --git a/lib/network/util/process_info_macos.dart b/lib/network/util/process_info_macos.dart new file mode 100644 index 0000000..b65ce3d --- /dev/null +++ b/lib/network/util/process_info_macos.dart @@ -0,0 +1,171 @@ +/* + * 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: ...'])` and +/// `Process.run('bash', ['-c', 'ps -p -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 `` 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 / ) +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, Int32); +typedef _ProcListPidsDart = int Function(int, int, Pointer, int); + +typedef _ProcPidInfoC = Int32 Function(Int32, Int32, Uint64, Pointer, Int32); +typedef _ProcPidInfoDart = int Function(int, int, int, Pointer, int); + +typedef _ProcPidFdInfoC = Int32 Function(Int32, Int32, Int32, Pointer, Int32); +typedef _ProcPidFdInfoDart = int Function(int, int, int, Pointer, int); + +typedef _ProcPidPathC = Int32 Function(Int32, Pointer, Uint32); +typedef _ProcPidPathDart = int Function(int, Pointer, 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) { + final pidBufSize = _procListPids(_kProcAllPids, 0, nullptr, 0); + if (pidBufSize <= 0) return null; + + final pidBuf = calloc(pidBufSize); + final sockBuf = calloc(_kSizeofSocketFdInfo); + + try { + final actual = _procListPids(_kProcAllPids, 0, pidBuf.cast(), pidBufSize); + if (actual <= 0) return null; + + final pidView = pidBuf.cast().asTypedList(actual ~/ 4); + + for (final pid in pidView) { + if (pid <= 0) continue; + + final fdSize = _procPidInfo(pid, _kProcPidListFds, 0, nullptr, 0); + if (fdSize <= 0) continue; + + final fdBuf = calloc(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) continue; + + final sockView = ByteData.sublistView(sockBuf.asTypedList(_kSizeofSocketFdInfo)); + 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(_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().toDartString(length: ret); + } finally { + calloc.free(buf); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 37e4f64..54d393d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: brotli: ^0.6.0 html: ^0.15.6 xml: ^6.6.1 + ffi: ^2.1.0 # macos_window_utils: 1.6.1 win32audio: ^1.3.1 vclibs: ^0.1.3