Skip to content

Commit a86604f

Browse files
authored
fix: 🥵 100% CPU usage in reconnectd when changing port settings (#474)
This one is painfully nuanced and boils down to how I was managing ncp thread lifecycles. It turns out (perhaps unsurprisingly) that mixing-and-matching `pthread` and `Thread` APIs doesn't go smoothly, and `pthread_join` was not working as expected with `Thread` instances. This meant that I was failing to wait for ncp threads to terminate before starting new ones which, coupled with some lingering global state in plptools (in multi-device scenarios) meant that reconnectd could get into an active spin trying to use an unavailable TCP port. Almost all of the work to fix this has been done in plptools see jbmorley/plptools#1), moving the ncp lifecycle management into the C/C++ code to ensure it uses a singe consistent API.
1 parent d2973c8 commit a86604f

File tree

14 files changed

+219
-132
lines changed

14 files changed

+219
-132
lines changed

‎macos/Reconnect/Model/DeviceModel.swift‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,10 @@ class DeviceModel: Identifiable, Equatable, @unchecked Sendable {
241241
}
242242
} catch {
243243
print("Failed to get owner info with error \(error).")
244-
let alert = NSAlert(error: error)
245-
alert.runModal()
244+
DispatchQueue.main.async {
245+
let alert = NSAlert(error: error)
246+
alert.runModal()
247+
}
246248
}
247249
}
248250
}

‎macos/ReconnectCore/Package.resolved‎

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎macos/ReconnectCore/Package.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ let package = Package(
2828
dependencies: [
2929
.product(name: "Diligence", package: "diligence"),
3030
.product(name: "OpoLuaCore", package: "opolua"),
31-
.product(name: "ncp", package: "plptools"),
31+
.product(name: "plptools", package: "plptools"),
3232
],
3333
resources: [
3434
.process("Resources"),

‎macos/ReconnectCore/Sources/ReconnectCore/Extensions/rfsv.errs.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import Foundation
2020

21-
import ncp
21+
import plptools
2222

2323
extension PLPToolsError {
2424

‎macos/ReconnectCore/Sources/ReconnectCore/Model/ReconnectError.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import Foundation
2020

21-
import ncp
21+
import plptools
2222

2323
public enum ReconnectError: Error {
2424
case unknown

‎macos/ReconnectCore/Sources/ReconnectCore/PLP/FileServer.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import Foundation
2020

21-
import ncp
21+
import plptools
2222
import OpoLuaCore
2323

2424
// Thread-safe FileServer implementation.

‎macos/ReconnectCore/Sources/ReconnectCore/PLP/NCP.swift‎

Lines changed: 24 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import os
2020
import Foundation
2121

22-
import ncp
22+
import plptools
2323

2424
// Callbacks on main.
2525
public protocol NCPDelegate: NSObject {
@@ -29,6 +29,7 @@ public protocol NCPDelegate: NSObject {
2929
}
3030

3131
// TODO: NCPSession?
32+
3233
public class NCP {
3334

3435
public struct DeviceConfiguration: Equatable, Hashable {
@@ -48,77 +49,54 @@ public class NCP {
4849
public let device: DeviceConfiguration
4950
public let port: Int32
5051

51-
private let lock = NSLock()
5252
private let logger = Logger(subsystem: "PLP", category: "Server")
53+
private var session: UnsafeMutableRawPointer?
5354

54-
// Synchronized with lock.
55-
private var threadID: pthread_t? = nil
56-
private var isCancelled: Bool = false
57-
58-
func threadEntryPoint() {
59-
60-
setup_signal_handlers()
55+
private var callback: NCPStatusCallback?
6156

62-
lock.withLock {
63-
threadID = pthread_self()
64-
}
57+
public func start() {
58+
dispatchPrecondition(condition: .notOnQueue(.main))
6559

60+
// Set up the callback.
6661
let context = Unmanaged.passRetained(self).toOpaque()
67-
let callback: statusCallback_t = { context, status in
62+
callback = { context, status in
6863
guard let context else {
6964
return
7065
}
7166
let ncp = Unmanaged<NCP>.fromOpaque(context).takeUnretainedValue()
72-
DispatchQueue.main.sync {
67+
// We dispatch async here to ensure we can't deadlock against `stop` calls on the main queue.
68+
DispatchQueue.main.async {
7369
let isConnected = status == 1 ? true : false
7470
ncp.delegate?.ncp(ncp, didChangeConnectionState: isConnected)
7571
}
7672
}
7773

78-
while true {
79-
logger.notice("Starting NCP for device '\(self.device.path)' baud rate \(self.device.baudRate)...")
80-
ncpd(port, device.baudRate, "127.0.0.1", device.path, 0x0000, callback, context)
81-
DispatchQueue.main.async {
82-
self.delegate?.ncp(self, didChangeConnectionState: false)
83-
}
84-
logger.notice("NCP session ended.")
74+
logger.notice("Starting NCP for device '\(self.device.path)' baud rate \(self.device.baudRate)...")
75+
session = ncp_session_init(port, device.baudRate, "127.0.0.1", device.path, false, 0, callback, context)
8576

86-
// Check to see if we need to exit.
87-
let isCancelled: Bool = lock.withLock {
88-
return self.isCancelled
89-
}
90-
if isCancelled {
91-
logger.notice("NCP thread exiting.")
92-
return
93-
}
77+
// While it's pretty grim, but we to the main thread to start each ncp session. This ensures we inherit the
78+
// correct thread priority when the session is started using `pthread_create`, without having to change the
79+
// internal plptools implementation. Thankfully, this does almost no work except for setting up the thread state
80+
// and starting it.
81+
DispatchQueue.main.sync {
82+
ncp_session_start(self.session)
9483
}
84+
9585
}
9686

9787
public init(device: DeviceConfiguration, port: Int32) {
9888
self.device = device
9989
self.port = port
100-
101-
}
102-
103-
public func start() {
104-
let thread = Thread(block: threadEntryPoint)
105-
thread.start()
10690
}
10791

10892
public func stop() {
109-
110-
// Cancel the thread and get its id.
111-
let threadID = lock.withLock {
112-
isCancelled = true
113-
return self.threadID
114-
}
115-
guard let threadID = threadID else {
93+
dispatchPrecondition(condition: .notOnQueue(.main))
94+
guard let session else {
11695
return
11796
}
118-
119-
// Signal the thread to stop it and then join it to ensure it's stopped.
120-
pthread_kill(threadID, SIGINT)
121-
pthread_join(threadID, nil)
97+
ncp_session_cancel(session)
98+
ncp_session_wait(session)
99+
self.session = nil
122100
}
123101

124102
}

‎macos/ReconnectCore/Sources/ReconnectCore/PLP/PLPToolsError.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import Foundation
2020

21-
import ncp
21+
import plptools
2222

2323
public typealias PLPToolsError = rfsv.errs
2424

‎macos/ReconnectCore/Sources/ReconnectCore/PLP/RemoteCommandServicesClient.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import Foundation
2020

21-
import ncp
21+
import plptools
2222

2323
public class RemoteCommandServicesClient {
2424

‎macos/ReconnectCore/Sources/ReconnectCore/Utilities/SerialDeviceMonitor.swift‎

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import IOKit.serial
2222

2323
public protocol SerialDeviceMonitorDelegate: NSObject {
2424

25-
func serialDeviceMonitor(serialDeviceMonitor: SerialDeviceMonitor, didAddDevice device: String)
26-
func serialDeviceMonitor(serialDeviceMonitor: SerialDeviceMonitor, didRemoveDevice device: String)
25+
func serialDeviceMonitor(serialDeviceMonitor: SerialDeviceMonitor, didAddDevices devices: [String])
26+
func serialDeviceMonitor(serialDeviceMonitor: SerialDeviceMonitor, didRemoveDevices devices: [String])
2727

2828
}
2929

@@ -36,6 +36,8 @@ public class SerialDeviceMonitor {
3636
}
3737

3838
public func start() {
39+
dispatchPrecondition(condition: .onQueue(.main))
40+
3941
let matchingDict = IOServiceMatching(kIOSerialBSDServiceValue)
4042
var notifyPort: IONotificationPortRef?
4143
var addedIterator: io_iterator_t = 0
@@ -88,15 +90,33 @@ public class SerialDeviceMonitor {
8890
// https://developer.apple.com/documentation/iokit/1514362-ioserviceaddmatchingnotification
8991

9092
// Handle existing removals.
93+
var removals: [String] = []
9194
while case let service = IOIteratorNext(removedIterator), service != 0 {
92-
deviceRemoved(service: service)
95+
defer { IOObjectRelease(service) }
96+
guard let deviceName = name(for: service) else {
97+
continue
98+
}
99+
removals.append(deviceName)
93100
}
101+
delegate?.serialDeviceMonitor(serialDeviceMonitor: self, didRemoveDevices: removals)
94102

95103
// Handle existing additions.
104+
var additions: [String] = []
96105
while case let service = IOIteratorNext(addedIterator), service != 0 {
97-
deviceAdded(service: service)
106+
defer { IOObjectRelease(service) }
107+
guard let deviceName = name(for: service) else {
108+
continue
109+
}
110+
additions.append(deviceName)
98111
}
112+
delegate?.serialDeviceMonitor(serialDeviceMonitor: self, didAddDevices: additions)
113+
114+
}
99115

116+
func name(for service: io_object_t) -> String? {
117+
return IORegistryEntryCreateCFProperty(service,
118+
kIOCalloutDeviceKey as CFString,
119+
kCFAllocatorDefault, 0)?.takeUnretainedValue() as? String
100120
}
101121

102122
func stop() {
@@ -105,26 +125,20 @@ public class SerialDeviceMonitor {
105125

106126
func deviceAdded(service: io_object_t) {
107127
dispatchPrecondition(condition: .onQueue(.main))
108-
defer {
109-
IOObjectRelease(service)
110-
}
111-
if let deviceName = IORegistryEntryCreateCFProperty(service,
112-
kIOCalloutDeviceKey as CFString,
113-
kCFAllocatorDefault, 0)?.takeUnretainedValue() as? String {
114-
delegate?.serialDeviceMonitor(serialDeviceMonitor: self, didAddDevice: deviceName)
128+
defer { IOObjectRelease(service) }
129+
guard let deviceName = name(for: service) else {
130+
return
115131
}
132+
delegate?.serialDeviceMonitor(serialDeviceMonitor: self, didAddDevices: [deviceName])
116133
}
117134

118135
func deviceRemoved(service: io_object_t) {
119136
dispatchPrecondition(condition: .onQueue(.main))
120-
defer {
121-
IOObjectRelease(service)
122-
}
123-
if let deviceName = IORegistryEntryCreateCFProperty(service,
124-
kIOCalloutDeviceKey as CFString,
125-
kCFAllocatorDefault, 0)?.takeUnretainedValue() as? String {
126-
delegate?.serialDeviceMonitor(serialDeviceMonitor: self, didRemoveDevice: deviceName)
137+
defer { IOObjectRelease(service) }
138+
guard let deviceName = name(for: service) else {
139+
return
127140
}
141+
delegate?.serialDeviceMonitor(serialDeviceMonitor: self, didRemoveDevices: [deviceName])
128142
}
129143

130144
}

0 commit comments

Comments
 (0)