Skip to content

Commit 97e16ba

Browse files
committed
Add keyboard layout caching and limit Carbon TIS calls to main thread
Fucking hell… so apparently this still can crash when overdoing TIS calls. Two failsafe measures: - Only call TIS on main thread, which can't always be possible. - Cache layouts, so TIS calls are limited by design. The thread-safety test has also been updated to cause actual crashing when these safety features are disabled.
1 parent f79fc54 commit 97e16ba

2 files changed

Lines changed: 100 additions & 29 deletions

File tree

source/Observatory/Keyboard/Keyboard.Key.swift

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import AppKit.NSEvent
22
import Carbon
33

4-
/// Represents a keyboard key. Don't forget – there are different keyboard layouts for handling different languages.
4+
/// Represents a physical keyboard key. Don't forget – there are different keyboard layouts for handling different languages.
55
public struct KeyboardKey: RawRepresentable {
66
public init(rawValue: Int) { self.rawValue = rawValue }
77
public init(_ rawValue: Int) { self.init(rawValue: rawValue) }
@@ -29,6 +29,7 @@ public struct KeyboardKey: RawRepresentable {
2929
return nil
3030
}
3131

32+
/// The virtual key code, CGKeyCode.
3233
public let rawValue: Int
3334

3435
public static let a: KeyboardKey = .init(kVK_ANSI_A)
@@ -152,12 +153,14 @@ public struct KeyboardKey: RawRepresentable {
152153
public static let f19: KeyboardKey = .init(kVK_F19)
153154
public static let f20: KeyboardKey = .init(kVK_F20)
154155

156+
/// Returns the key name in current ASCII layout if available, and if not, falls back to the current layout no matter whether ASCII or not.
155157
public var name: String? {
156158
self.name(custom: Self.names)
157159
}
158160

161+
/// Returns the key name in current ASCII layout if available, and if not, falls back to the current layout no matter whether ASCII or not.
159162
public func name(custom map: [KeyboardKey: String]) -> String? {
160-
self.name(layout: .ascii, custom: map)
163+
self.name(layout: .ascii, custom: map) ?? self.name(layout: .current, custom: map)
161164
}
162165

163166
public func name(layout: Layout, custom map: [KeyboardKey: String]? = nil) -> String? {
@@ -240,7 +243,7 @@ extension KeyboardKey: Equatable, Hashable {
240243
}
241244

242245
extension KeyboardKey: CustomStringConvertible {
243-
public var description: String { self.name(custom: Self.names) ?? "" }
246+
public var description: String { self.name(custom: Self.names) ?? "Key Code: \(self.rawValue)" }
244247
}
245248

246249
extension KeyboardKey {
@@ -251,29 +254,78 @@ extension KeyboardKey {
251254
}
252255

253256
extension KeyboardKey.Layout {
254-
/// Needed for serializing access to Carbons TIS keyboard layout APIs, which can crash under concurrent calls,
257+
/// Needed for serializing access to Carbon's TIS keyboard layout APIs, which can crash under concurrent calls,
255258
/// ensuring layout data retrieval is thread-safe.
256259
private static let lock = NSLock()
257260

261+
/// Cache layout data by input source – this minimizes calls to Carbon's TIS, which are super-unstable and thread-unsafe…
262+
fileprivate static var cache: [Self: Data] = [:]
263+
private static func invalidateCaches() { Self.lock.withLock({ Self.cache = [:] }) }
264+
265+
/// Input source change observers.
266+
private static var observers: [NSObjectProtocol] = []
267+
private static func observe() -> [NSObjectProtocol] {
268+
// https://leopard-adc.pepas.com/documentation/TextFonts/Reference/TextInputSourcesReference/TextInputSourcesReference.pdf
269+
let notifications = [kTISNotifySelectedKeyboardInputSourceChanged, kTISNotifyEnabledKeyboardInputSourcesChanged].compactMap({ Notification.Name($0 as String) })
270+
return notifications.map({ DistributedNotificationCenter.default().addObserver(forName: $0, object: nil, queue: nil, using: { _ in Self.invalidateCaches() }) })
271+
}
272+
273+
258274
/// The unicode keyboard layout, with some great insight from:
259275
/// - https://jongampark.wordpress.com/2015/07/17.
260276
/// - https://github.com/cocoabits/MASShortcut/issues/60
261277
public var data: Data? {
262-
Self.lock.lock()
263-
defer { Self.lock.unlock() }
278+
// We still want the locking, but not while waiting for the main-thread dispatch, as it can produce short-deadlocks.
279+
280+
var data: Data? = Self.lock.withLock({
281+
if let data = Self.cache[self] { return data }
282+
if Self.observers.isEmpty { Self.observers = Self.observe() }
283+
return nil
284+
})
285+
286+
do {
287+
data = try Thread.mainly(timeout: .milliseconds(50), {
288+
Self.lock.withLock({
289+
// ✊ What is interesting is that kTISPropertyUnicodeKeyLayoutData is still used when it queries last ASCII capable keyboard. It
290+
// is TISCopyCurrentASCIICapableKeyboardLayoutInputSource() not TISCopyCurrentASCIICapableKeyboardInputSource() to call. The latter
291+
// does not guarantee that it would return an keyboard input with a layout.
292+
let inputSource = switch self {
293+
case .ascii: TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue()
294+
case .current: TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
295+
}
296+
guard let inputSource else { return nil }
297+
guard let data = TISGetInputSourceProperty(inputSource, kTISPropertyUnicodeKeyLayoutData) else { return nil }
298+
guard let data = Unmanaged<AnyObject>.fromOpaque(data).takeUnretainedValue() as? NSData, data.count > 0 else { return nil }
299+
// Hard-copy the data to avoid any external modifications.
300+
return Data(data as Data)
301+
})
302+
})
303+
} catch {
304+
NSLog("Failed to retrieve keyboard layout data: TIS API couldn't be called on the main thread.")
305+
}
264306

265-
// ✊ What is interesting is that kTISPropertyUnicodeKeyLayoutData is still used when it queries last ASCII capable keyboard. It
266-
// is TISCopyCurrentASCIICapableKeyboardLayoutInputSource() not TISCopyCurrentASCIICapableKeyboardInputSource() to call. The latter
267-
// does not guarantee that it would return an keyboard input with a layout.
307+
if let data { Self.lock.withLock({ Self.cache[self] = data }) }
308+
return data
309+
}
310+
}
268311

269-
var inputSource: TISInputSource?
270-
switch self {
271-
case .ascii: inputSource = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue()
272-
case .current: inputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
312+
extension Thread {
313+
fileprivate enum Error: Swift.Error { case timeout }
314+
@discardableResult fileprivate static func mainly<T>(timeout: DispatchTimeInterval, _ action: @escaping () -> T) throws -> T {
315+
if Thread.isMainThread { return action() }
316+
let semaphore = (dispatch: DispatchSemaphore(value: 0), work: DispatchSemaphore(value: 0))
317+
var result: T?
318+
var item: DispatchWorkItem?
319+
item = DispatchWorkItem {
320+
// If we timed out before the item even started, we cancel it and it should no-op.
321+
semaphore.dispatch.signal()
322+
defer { semaphore.work.signal() }
323+
if item?.isCancelled == false { result = action() }
273324
}
274-
guard let inputSource else { return nil }
275-
guard let data = TISGetInputSourceProperty(inputSource, kTISPropertyUnicodeKeyLayoutData) else { return nil }
276-
guard let data = Unmanaged<AnyObject>.fromOpaque(data).takeUnretainedValue() as? NSData, data.count > 0 else { return nil }
277-
return Data(referencing: data)
325+
// Only time out if the main queue couldn't begin running our block. But once started, wait for completion…
326+
if let item { DispatchQueue.main.async(execute: item) }
327+
if semaphore.dispatch.wait(timeout: .now() + timeout) == .timedOut { item?.cancel(); throw Error.timeout }
328+
semaphore.work.wait()
329+
if let result { return result } else { throw Error.timeout }
278330
}
279331
}

source/Observatory/Test/Keyboard/Test.Keyboard.Key.swift

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,38 @@ internal class KeyboardKeySpec: Spec {
3838
}
3939

4040
it("can handle multi-threading") {
41-
let queue = OperationQueue()
42-
queue.maxConcurrentOperationCount = 25
43-
for _ in 0 ..< 100 {
44-
queue.addOperation({
45-
for _ in 0 ..< 1 {
41+
waitUntil(action: { done in
42+
let queue = OperationQueue()
43+
queue.maxConcurrentOperationCount = max(4, ProcessInfo.processInfo.activeProcessorCount * 2)
44+
45+
// Stress-testing on main vs. background thread – this is what causes the failures. It appears TIS call must first originate
46+
// from the main thread, so it's placed on the main queue before layout data will attempt to call this. Note, under other
47+
// circumstances, this can / would be fine, so it's engineered as it is is on purpose.
48+
49+
// To verify that this fails two things need to be done:
50+
// - Disable caching inside Layout's data.
51+
// - Disable main-thread calling inside Thread's mainly, or don't use mainly at all.
52+
53+
for _ in 0 ..< 2500 {
54+
queue.addOperation({
55+
DispatchQueue.main.async {
56+
autoreleasepool {
57+
// This line seems to be the culprit on macOS Sequoia 15.7.2 (24G325) – without it, the test passes.
58+
_ = TISCreateInputSourceList(nil, true).takeRetainedValue()
59+
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { fatalError() }
60+
guard TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) != nil else { fatalError() }
61+
}
62+
}
63+
4664
autoreleasepool {
47-
_ = KeyboardKey.a.name(layout: .ascii)
48-
_ = KeyboardKey.escape.name(layout: .ascii)
65+
// Without proper main-thread handling, this would crash…
66+
expect(KeyboardKey.Layout.allCases.randomElement()!.data) != nil
4967
}
50-
}
51-
})
52-
}
53-
queue.waitUntilAllOperationsAreFinished()
68+
})
69+
}
70+
71+
queue.addBarrierBlock({ DispatchQueue.main.async(execute: done) })
72+
})
5473
}
5574

5675
it("can get layout data quickly") {
@@ -69,7 +88,7 @@ extension UCKeyboardLayout {
6988
// Using properties filter to get the language doesn't work as expected and returns a different input… 🤔
7089
let inputSources = TISCreateInputSourceList(nil, true).takeRetainedValue() as? [TISInputSource]
7190
guard let inputSource = inputSources?.first(where: { unsafeBitCast(TISGetInputSourceProperty($0, kTISPropertyInputSourceID), to: CFString.self) as String == id }) else { return nil }
72-
guard let data = TISGetInputSourceProperty(inputSource, kTISPropertyUnicodeKeyLayoutData) else { return nil }
91+
guard let data = TISGetInputSourceProperty(inputSource, kTISPropertyUnicodeKeyLayoutData) else { return nil }
7392
guard let data = Unmanaged<AnyObject>.fromOpaque(data).takeUnretainedValue() as? NSData, data.count > 0 else { return nil }
7493
return Data(referencing: data)
7594
}

0 commit comments

Comments
 (0)