Skip to content

Commit a41350e

Browse files
refactor: optimize port scanning and process command retrieval using sysctl
1 parent b158544 commit a41350e

File tree

1 file changed

+79
-57
lines changed

1 file changed

+79
-57
lines changed

platforms/macos/Sources/PortScanner.swift

Lines changed: 79 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import Darwin
23

34
/**
45
* PortScanner is a Swift actor that safely scans system ports and manages process termination.
@@ -63,66 +64,98 @@ actor PortScanner: PortScannerProtocol {
6364

6465
guard !output.isEmpty else { return [] }
6566

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)
6770
return parseLsofOutput(output, commands: commands)
6871
}
6972

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+
7086
/**
71-
* Retrieves full command line information for all processes.
87+
* Retrieves full command lines for specific processes via sysctl.
7288
*
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.
7492
*
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
7696
*
97+
* @param pids - Set of process IDs to query
7798
* @returns Dictionary mapping PID to full command string
7899
*/
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)
98103

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
103107
}
104108
}
105109

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+
}
120112

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
123156
}
124157

125-
return commands
158+
return args.isEmpty ? nil : args.joined(separator: " ")
126159
}
127160

128161
/**
@@ -270,19 +303,8 @@ actor PortScanner: PortScannerProtocol {
270303
* @returns True if the kill command executed successfully (exit code 0)
271304
*/
272305
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
286308
}
287309

288310
/**

0 commit comments

Comments
 (0)