Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,17 @@ jobs:
- name: Test Gesture Capture Models
run: bash Scripts/test-gesture-models.sh

- name: Test MessageAttribute
run: bash Scripts/test-message-attribute.sh

- name: Test Activation Package
working-directory: Packages/LookInsideActivation
run: swift test

- name: Test Host Core Package
working-directory: Packages/LookInsideHostCore
run: swift test

- name: Build LookInside
run: |
xcodebuild \
Expand Down
1 change: 1 addition & 0 deletions LookInside.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@
MCPBridge/MCPBridgeLiveDocumentLookup.swift,
MCPBridge/MCPBridgeModificationService.swift,
MCPBridge/MCPBridgeObjectIdentifierList.swift,
MCPBridge/MCPBridgeRecoveryHint.swift,
MCPBridge/MCPBridgeRefreshService.swift,
MCPBridge/MCPBridgeScreenshotService.swift,
MCPBridge/MCPBridgeSearchQuery.swift,
Expand Down
44 changes: 41 additions & 3 deletions LookInside/Base/Message/MessageAttribute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,27 @@ class MessageAttribute: NSObject {
}
}

/// Identifies a subscription by object identity, for the duplicate check.
/// An identity outlives its object, so a hit only counts when the stored
/// subscriber still points at the very same objects.
private struct SubscriptionKey: Hashable {
let target: ObjectIdentifier
let action: Selector
let relatedObject: ObjectIdentifier?
}

private(set) var currentValue: Any?

/// In subscription order, which is the order actions are sent in.
private var subscribers: [Subscriber] = []
/// The duplicate check: a linear scan of `subscribers` made subscribing n
/// targets O(n²), and the 3D preview subscribes each of its nodes to three
/// shared preference attributes, so opening a 6,000-node document spent
/// seconds in it.
private var subscribersByKey: [SubscriptionKey: Subscriber] = [:]
/// Subscriptions added since subscribers whose target went away were last
/// dropped.
private var subscriptionsSincePruning = 0

override init() {
super.init()
Expand All @@ -91,7 +109,7 @@ class MessageAttribute: NSObject {
currentValue = value

let ignored = ignoreSubscriber as AnyObject?
subscribers.removeAll { $0.target == nil }
pruneSubscribersWhoseTargetWentAway()
for subscriber in subscribers {
guard let target = subscriber.target, target !== ignored else {
continue
Expand All @@ -117,10 +135,21 @@ class MessageAttribute: NSObject {
return
}
let related = relatedObject as AnyObject?
if subscribers.contains(where: { $0.matches(target: target, action: action, relatedObject: related) }) {
let key = SubscriptionKey(target: ObjectIdentifier(target), action: action, relatedObject: related.map { ObjectIdentifier($0) })
if let existing = subscribersByKey[key], existing.matches(target: target, action: action, relatedObject: related) {
return
}
subscribers.append(Subscriber(target: target, action: action, relatedObject: related))
let subscriber = Subscriber(target: target, action: action, relatedObject: related)
subscribers.append(subscriber)
subscribersByKey[key] = subscriber

// Preview nodes go away with their document while the preference
// attributes they subscribed to live on, and only a value change used
// to drop them.
subscriptionsSincePruning += 1
if subscriptionsSincePruning >= max(subscribers.count / 2, 1024) {
pruneSubscribersWhoseTargetWentAway()
}

if sendAtOnce {
let params = MessageActionParameters()
Expand All @@ -129,6 +158,15 @@ class MessageAttribute: NSObject {
NSApp.sendAction(action, to: target, from: params)
}
}

private func pruneSubscribersWhoseTargetWentAway() {
subscriptionsSincePruning = 0
guard subscribers.contains(where: { $0.target == nil }) else {
return
}
subscribers.removeAll { $0.target == nil }
subscribersByKey = subscribersByKey.filter { $0.value.target != nil }
}
}

final class DoubleMessageAttribute: MessageAttribute {
Expand Down
118 changes: 85 additions & 33 deletions LookInside/Connection/ConnectionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ struct ConnectionPush {
/// Connection state the manager keeps for one channel.
@MainActor
final class ServerChannelConnectionState {
/// Requests sent on this channel whose responses have not all arrived.
var activeRequests: [ConnectionRequest] = []
/// Requests sent on this channel whose responses have not all arrived:
/// one of a type at a time, pings excepted.
let activeRequests = ActiveRequests<ConnectionRequest>(typesThatMayOverlap: [UInt32(LookinRequestTypePing)])
let licenseHandshake = LicenseHandshakeGate()
/// Identifies this channel to the activation runtime's signing policy,
/// which remembers a failed handshake per channel.
Expand All @@ -50,7 +51,9 @@ final class ServerChannelConnectionState {
}

func remove(_ request: ConnectionRequest) {
activeRequests.removeAll { $0 === request }
// Every way a request ends goes through here.
request.endSignpostInterval()
activeRequests.remove(request)
}
}

Expand All @@ -68,6 +71,7 @@ private final class ConnectionPort {
}

@Loggable(.internal, subsystem: "com.lookinside.app")
@Signpostable(subsystem: "com.lookinside.app", category: "RefreshPerformance")
@MainActor
final class ConnectionManager: NSObject {
static let shared = ConnectionManager()
Expand Down Expand Up @@ -253,8 +257,11 @@ final class ConnectionManager: NSObject {
/// each response frame, then a failure or a completion.
///
/// An older request of the same type still waiting on this channel fails
/// with `LookinErrCode_Discard` when this one is sent.
func request(type: UInt32, data: NSObject?, channel: ServerChannel, sink: ResponseSink) {
/// with `LookinErrCode_Discard` when this one is sent. With
/// `yieldsToRequestInFlight`, this one fails that way instead, unsent,
/// while one of its type is in flight. A request cancelled while its ping
/// runs (`cancelRequest(receivingEventsWith:channel:)`) is not sent.
func request(type: UInt32, data: NSObject?, channel: ServerChannel, yieldsToRequestInFlight: Bool = false, sink: ResponseSink) {
// An app in the background keeps its channel but cannot answer; a
// long ping timeout would hold up the launch window's app list.
let pingTimeout: TimeInterval = type == UInt32(LookinRequestTypeApp) ? 0.5 : 2
Expand All @@ -268,11 +275,13 @@ final class ConnectionManager: NSObject {
return
}
let sendRequest = { [weak self] in
guard !sink.isCancelled else { return }
self?.sendFrame(
type: type,
channel: channel,
data: data,
timeoutInterval: Self.timeoutInterval(forRequestType: type),
yieldsToRequestInFlight: yieldsToRequestInFlight,
sink: sink
)
}
Expand All @@ -292,18 +301,28 @@ final class ConnectionManager: NSObject {
sendFrame(type: UInt32(LookinRequestTypePing), channel: channel, data: nil, timeoutInterval: pingTimeout, sink: pingSink)
}

/// Ends the waiting request of `type` on `channel` and reports it as
/// completed.
func cancelRequest(type: UInt32, channel: ServerChannel?) {
guard let state = channel?.connectionState,
let request = state.activeRequests.first(where: { $0.type == type })
else {
return
/// Ends the request whose events go to `sink` and reports it as
/// completed, at once: also while its ping runs, and then it is never
/// sent. Other requests of its type, another client's, go on. Returns
/// whether it was in flight on `channel`, that is whether the Server is
/// working on it.
@discardableResult
func cancelRequest(receivingEventsWith sink: ResponseSink, channel: ServerChannel?) -> Bool {
let state = channel?.connectionState
guard let request = state?.activeRequests.first(where: { $0.sink === sink }) else {
sink.cancel()
return false
}
request.endTimeoutCount()
state.remove(request)
request.sink.send(.completion)
#log(.default, "Lookin - request cancelled by the user, type:\(NSNumber(value: type), privacy: .public)")
state?.remove(request)
sink.cancel()
#log(.default, "Lookin - request cancelled, type:\(NSNumber(value: request.type), privacy: .public)")
return true
}

/// Whether a request of `type` is in flight on `channel`.
func isRequestInFlight(type: UInt32, channel: ServerChannel?) -> Bool {
channel?.connectionState.activeRequests.containsRequest(ofType: type) ?? false
}

/// Response frames of one request, until it completes. The stream
Expand Down Expand Up @@ -404,41 +423,71 @@ final class ConnectionManager: NSObject {
}

/// Sends one frame and tracks it until its last response, a failure or
/// its timeout.
func sendFrame(type: UInt32, channel: ServerChannel, data: NSObject?, timeoutInterval: TimeInterval, sink: ResponseSink) {
/// its timeout. With `yieldsToRequestInFlight`, a request of its type in
/// flight keeps the channel and this one fails as discarded, unsent.
func sendFrame(
type: UInt32,
channel: ServerChannel,
data: NSObject?,
timeoutInterval: TimeInterval,
yieldsToRequestInFlight: Bool = false,
sink: ResponseSink
) {
guard channel.isConnected else {
sink.send(.failure(ConnectionError.noConnect))
return
}
let state = channel.connectionState
if type != UInt32(LookinRequestTypePing) {
let tag = UInt32(truncatingIfNeeded: Int64(Date().timeIntervalSince1970))
let request = ConnectionRequest(
type: type,
tag: tag,
timeoutInterval: timeoutInterval,
sink: sink,
isResponseWaiting: { [weak channel] request in
// Answered in time, and waiting behind other frames here.
channel?.hasUndeliveredFrame(type: request.type, tag: request.tag) ?? false
},
onTimeout: { [weak channel] request in
request.sink.send(.failure(ConnectionError.timeout))
channel?.connectionState.remove(request)
}
)

// Track the request before the write: the write completion and the
// reply reach the main queue independently, and on loopback the
// reply can come first. A request registered only on completion
// refuses its own reply and then times out.
switch state.activeRequests.admit(request, yieldsToRequestInFlight: yieldsToRequestInFlight) {
case .refused:
#log(.default, "LookinClient - request yields to the one in flight, type:\(NSNumber(value: type), privacy: .public)")
sink.send(.failure(ConnectionError.discarded))
return
case let .admitted(replacedRequests):
// A newer request replaces an older one of the same type, which
// fails.
for discarded in state.activeRequests where discarded.type == type {
discarded.sink.send(.failure(ConnectionError.discarded))
for discarded in replacedRequests {
discarded.endTimeoutCount()
state.remove(discarded)
discarded.sink.send(.failure(ConnectionError.discarded))
#log(.default, "LookinClient - will discard request, type:\(NSNumber(value: discarded.type), privacy: .public), tag:\(NSNumber(value: discarded.tag), privacy: .public)")
}
}

let tag = UInt32(truncatingIfNeeded: Int64(Date().timeIntervalSince1970))
let request = ConnectionRequest(type: type, tag: tag, timeoutInterval: timeoutInterval, sink: sink) { [weak channel] request in
request.sink.send(.failure(ConnectionError.timeout))
channel?.connectionState.remove(request)
}

let attachment = ConnectionAttachment()
attachment.data = data
let payload = archivedPayload(rootObject: attachment)
// Track the request before the write: the write completion and the
// reply reach the main queue independently, and on loopback the
// reply can come first. A request registered only on completion
// refuses its own reply and then times out.
state.activeRequests.append(request)
switch Int(type) {
case LookinRequestTypeHierarchy:
request.signpostInterval = #signpost(.begin, "Hierarchy request")
case LookinRequestTypeHierarchyDetails:
request.signpostInterval = #signpost(.begin, "Detail request")
default:
break
}
request.resetTimeoutCount()
channel.send(type: type, tag: tag, payload: payload) { error in
guard error != nil, state.activeRequests.contains(where: { $0 === request }) else { return }
guard error != nil, state.activeRequests.contains(request) else { return }
request.endTimeoutCount()
state.remove(request)
sink.send(.failure(ConnectionError.transport))
Expand Down Expand Up @@ -486,7 +535,10 @@ final class ConnectionManager: NSObject {
// graph of Lookin models and Foundation collections. Secure coding
// with NSObject as the allowed class warns on every frame, so the
// receive path stays non-secure on purpose.
let attachment = Self.unarchiveResponse(data)
#signpost(.event, "Response frame", "type=\(type, privacy: .public) bytes=\(data.count, privacy: .public)")
let attachment = #signpostInterval("Unarchive response") {
Self.unarchiveResponse(data)
}

if attachment?.appIsInBackground == true {
request.endTimeoutCount()
Expand Down
Loading
Loading