|
1 | 1 | import Foundation |
| 2 | +import Darwin |
2 | 3 |
|
3 | 4 | /** |
4 | 5 | * PortScanner is a Swift actor that safely scans system ports and manages process termination. |
@@ -63,66 +64,98 @@ actor PortScanner: PortScannerProtocol { |
63 | 64 |
|
64 | 65 | guard !output.isEmpty else { return [] } |
65 | 66 |
|
66 | | - let commands = await getProcessCommands() |
| 67 | + // Extract PIDs from lsof output, then get command lines via sysctl (no process spawn) |
| 68 | + let pids = extractPids(from: output) |
| 69 | + let commands = pids.isEmpty ? [:] : getProcessCommands(for: pids) |
67 | 70 | return parseLsofOutput(output, commands: commands) |
68 | 71 | } |
69 | 72 |
|
| 73 | + /// Extracts unique PIDs from raw lsof output (second column of each data line). |
| 74 | + nonisolated private func extractPids(from output: String) -> Set<Int> { |
| 75 | + var pids = Set<Int>() |
| 76 | + let lines = output.split(separator: "\n", omittingEmptySubsequences: false) |
| 77 | + for line in lines.dropFirst() { |
| 78 | + guard !line.isEmpty else { continue } |
| 79 | + let components = line.split(separator: " ", omittingEmptySubsequences: true) |
| 80 | + guard components.count >= 2, let pid = Int(components[1]) else { continue } |
| 81 | + pids.insert(pid) |
| 82 | + } |
| 83 | + return pids |
| 84 | + } |
| 85 | + |
70 | 86 | /** |
71 | | - * Retrieves full command line information for all processes. |
| 87 | + * Retrieves full command lines for specific processes via sysctl. |
72 | 88 | * |
73 | | - * Executes: `ps -axo pid,command` |
| 89 | + * Uses `sysctl(KERN_PROCARGS2)` to read each process's argv directly from the kernel. |
| 90 | + * This eliminates the `ps` process spawn entirely — no fork/exec, no Pipe, no FileHandle, |
| 91 | + * no Obj-C bridged objects. Pure C syscalls with minimal memory allocation. |
74 | 92 | * |
75 | | - * This provides more detailed command information than lsof alone. |
| 93 | + * Cost comparison per scan (typical system, ~30 listening ports): |
| 94 | + * ps approach: fork+exec + pipe I/O + ~500KB string + parse = ~5ms, ~500KB peak RAM |
| 95 | + * sysctl: ~30 syscalls × ~2KB each = ~0.3ms, ~4KB peak RAM |
76 | 96 | * |
| 97 | + * @param pids - Set of process IDs to query |
77 | 98 | * @returns Dictionary mapping PID to full command string |
78 | 99 | */ |
79 | | - private func getProcessCommands() async -> [Int: String] { |
80 | | - // Wrap Process/Pipe lifecycle in autoreleasepool; parsing stays outside |
81 | | - let output: String = autoreleasepool { |
82 | | - let process = Process() |
83 | | - process.executableURL = URL(fileURLWithPath: "/bin/ps") |
84 | | - process.arguments = ["-axo", "pid,command"] |
85 | | - |
86 | | - let pipe = Pipe() |
87 | | - process.standardOutput = pipe |
88 | | - process.standardError = FileHandle.nullDevice |
89 | | - |
90 | | - do { |
91 | | - try process.run() |
92 | | - |
93 | | - // CRITICAL: Read data BEFORE waitUntilExit to avoid deadlock. |
94 | | - // If the pipe buffer fills up (common with large process lists), |
95 | | - // ps blocks waiting to write. waitUntilExit first = deadlock. |
96 | | - let data = pipe.fileHandleForReading.readDataToEndOfFile() |
97 | | - process.waitUntilExit() |
| 100 | + nonisolated private func getProcessCommands(for pids: Set<Int>) -> [Int: String] { |
| 101 | + var commands: [Int: String] = [:] |
| 102 | + commands.reserveCapacity(pids.count) |
98 | 103 |
|
99 | | - return String(data: data, encoding: .utf8) ?? "" |
100 | | - } catch { |
101 | | - print("[PortScanner] Failed to get process commands: \(error.localizedDescription)") |
102 | | - return "" |
| 104 | + for pid in pids { |
| 105 | + if let cmd = commandLine(for: pid) { |
| 106 | + commands[pid] = cmd |
103 | 107 | } |
104 | 108 | } |
105 | 109 |
|
106 | | - guard !output.isEmpty else { return [:] } |
107 | | - |
108 | | - var commands: [Int: String] = [:] |
109 | | - // Use split for zero-copy Substring iteration |
110 | | - let lines = output.split(separator: "\n", omittingEmptySubsequences: false) |
111 | | - |
112 | | - for line in lines.dropFirst() { |
113 | | - // Trim whitespace using Substring operations |
114 | | - let trimmed = line.drop(while: { $0.isWhitespace }) |
115 | | - guard !trimmed.isEmpty else { continue } |
116 | | - |
117 | | - let parts = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) |
118 | | - guard parts.count >= 2, |
119 | | - let pid = Int(parts[0]) else { continue } |
| 110 | + return commands |
| 111 | + } |
120 | 112 |
|
121 | | - let fullCommand = String(parts[1]) |
122 | | - commands[pid] = fullCommand |
| 113 | + /// Reads a process's full command line (argv) from the kernel via sysctl. |
| 114 | + /// |
| 115 | + /// KERN_PROCARGS2 returns: [argc: Int32][exec_path\0][\0 padding][argv[0]\0][argv[1]\0]... |
| 116 | + /// We parse argc arguments and join them with spaces to match `ps -o command` output. |
| 117 | + /// Falls back to nil for system processes that restrict access (parseLsofOutput |
| 118 | + /// handles this by using the process name from lsof instead). |
| 119 | + nonisolated private func commandLine(for pid: Int) -> String? { |
| 120 | + var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, Int32(pid)] |
| 121 | + var size: Int = 0 |
| 122 | + |
| 123 | + // First call: get required buffer size |
| 124 | + guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, |
| 125 | + size > MemoryLayout<Int32>.size else { return nil } |
| 126 | + |
| 127 | + // Second call: read the data |
| 128 | + var buffer = [UInt8](repeating: 0, count: size) |
| 129 | + guard sysctl(&mib, 3, &buffer, &size, nil, 0) == 0 else { return nil } |
| 130 | + |
| 131 | + // Read argc from the first 4 bytes |
| 132 | + let argc = buffer.withUnsafeBytes { $0.load(as: Int32.self) } |
| 133 | + guard argc > 0 else { return nil } |
| 134 | + |
| 135 | + var pos = MemoryLayout<Int32>.size |
| 136 | + |
| 137 | + // Skip the executable path |
| 138 | + while pos < size && buffer[pos] != 0 { pos += 1 } |
| 139 | + // Skip null padding between exec path and argv |
| 140 | + while pos < size && buffer[pos] == 0 { pos += 1 } |
| 141 | + |
| 142 | + // Collect up to argc arguments (cap at 64 for safety) |
| 143 | + let maxArgs = min(argc, 64) |
| 144 | + var args = [String]() |
| 145 | + args.reserveCapacity(Int(maxArgs)) |
| 146 | + var collected: Int32 = 0 |
| 147 | + |
| 148 | + while pos < size && collected < maxArgs { |
| 149 | + let start = pos |
| 150 | + while pos < size && buffer[pos] != 0 { pos += 1 } |
| 151 | + if pos > start { |
| 152 | + args.append(String(decoding: buffer[start..<pos], as: UTF8.self)) |
| 153 | + } |
| 154 | + pos += 1 |
| 155 | + collected += 1 |
123 | 156 | } |
124 | 157 |
|
125 | | - return commands |
| 158 | + return args.isEmpty ? nil : args.joined(separator: " ") |
126 | 159 | } |
127 | 160 |
|
128 | 161 | /** |
@@ -270,19 +303,8 @@ actor PortScanner: PortScannerProtocol { |
270 | 303 | * @returns True if the kill command executed successfully (exit code 0) |
271 | 304 | */ |
272 | 305 | func killProcess(pid: Int, force: Bool = false) async -> Bool { |
273 | | - autoreleasepool { |
274 | | - let process = Process() |
275 | | - process.executableURL = URL(fileURLWithPath: "/bin/kill") |
276 | | - process.arguments = [force ? "-9" : "-15", String(pid)] |
277 | | - |
278 | | - do { |
279 | | - try process.run() |
280 | | - process.waitUntilExit() |
281 | | - return process.terminationStatus == 0 |
282 | | - } catch { |
283 | | - return false |
284 | | - } |
285 | | - } |
| 306 | + // Direct syscall — no Process/Pipe/FileHandle overhead |
| 307 | + Darwin.kill(Int32(pid), force ? SIGKILL : SIGTERM) == 0 |
286 | 308 | } |
287 | 309 |
|
288 | 310 | /** |
|
0 commit comments