Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allows for async functions/closures to be registered as routes #64

Merged
merged 4 commits into from
Jan 24, 2022

Conversation

jakaplan
Copy link
Contributor

Adds tests to match

@jakaplan
Copy link
Contributor Author

@amomchilov For your review. I dislike how much code duplication this introduces, but I couldn't figure out a way to reduce it due to the cascading nature of async.

Copy link
Contributor

@amomchilov amomchilov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll look at this in more depth over the weekend.

Until then, could you help me understand when a callback should be async?

// MARK: Route registration

private func checkUniqueRoute(route: XPCRoute) throws {
if self.routes.keys.contains(route) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDK how much it matters, but these two operations (checking it's not already there, and setting the new value) could be done in a single hash look-up with https://developer.apple.com/documentation/swift/dictionary/3127179-updatevalue

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh yeah I don't think any efficiency gains matter here, I'd rather consolidate the error generation into just one place.

@jakaplan
Copy link
Contributor Author

jakaplan commented Jan 22, 2022

Until then, could you help me understand when a callback should be async?

When the API user wants to create a callback which uses async functions within it, which will be an increasing percentage of functions over time. Without this the caller can't have any route with a reply (or one they want an error to be propagated back from) use async functions because wrapping the code in a Task won't allow for the return value (or thrown error) to be returned.

@jakaplan
Copy link
Contributor Author

jakaplan commented Jan 22, 2022

A realistic example could be an XPC service with a route provided a URL to a PDF. The service needs to download and then parse the PDF to extract some data. PDFs are a notorious exploit vector, so breaking out this component into an XPC service would be prudent. Using async functions to perform the download would be easiest. And there very well could be async functions involved in the data extraction (ex. running ML inference over it to extract text embedded in images).

@amomchilov
Copy link
Contributor

When the API user wants to create a callback which uses async functions within it, which will be an increasing percentage of functions over time. Without this the caller can't have any route with a reply (or one they want an error to be propagated back from) use async functions because wrapping the code in a Task won't allow for the return value (or thrown error) to be returned.

Ah yes, that makes perfect sense, thanks for the explanation

@@ -137,11 +149,21 @@ public class XPCServer {
/// - Throws: If this route has already been registered.
public func registerRoute(_ route: XPCRouteWithoutMessageWithoutReply,
handler: @escaping () throws -> Void) throws {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In retrospect, I don't think throwing here was a good idea (introduced in #28).

These errors can't reasonably recovered:

  1. "Do nothing" => the route doesn't act the way you expect
  2. "Name it something different" => makes it useless to the callers that expect a given name

Fatal erroring here might be more appropriate, so that route-registration doesn't need a try (removing try for brevity is a non-goal for me, but in some cases an unexpected try raises a suspicion error was thrown that can't really be caught)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me think about this. If I make this change, will do so as part of a separate PR.

self.routes[route.route] = ConstrainedXPCHandlerWithMessageWithoutReplySync(handler: handler)
}

/// Registers a route that has a message and can't receive a reply.
Copy link
Contributor

@amomchilov amomchilov Jan 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would use more context-specific terminology:

/// Registers a route that sends a message and doesn't expect a reply.

This is similar to how below you say "expects a reply" rather than "has a reply"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this was my attempt to be specific. It's not that it doesn't expect a reply, it very much cannot have one - it's quite literally impossible with the way the code is written.

@@ -219,15 +271,10 @@ public class XPCServer {

private func handleEvent(connection: xpc_connection_t, event: xpc_object_t) {
if xpc_get_type(event) == XPC_TYPE_DICTIONARY {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another case where my guard clause instinct tingles:

    private func handleEvent(connection: xpc_connection_t, event: xpc_object_t) {
        // Only dictionary types (and not anything which will be interpreted as an error) should be checked for
        // message acceptance as errors generated by libxpc can fail to meet the acceptor's criteria
        guard xpc_get_type(event) == XPC_TYPE_DICTIONARY else {
            self.errorHandler?(XPCError.fromXPCObject(event))
            return
        }

        guard self.messageAcceptor.acceptMessage(connection: connection, message: event) else {
            self.errorHandler?(.insecure)
            return
        }

        handleMessage(connection: connection, message: event)
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm maybe it's just my relative lack of usage of guard, but I find this a bit harder to read.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought, I do prefer this. Have updated the comment to make a bit more sense with this control flow.

Comment on lines 223 to 277
var reply = xpc_dictionary_create_reply(event)
do {
try handleMessage(connection: connection, message: event, reply: &reply)
} catch {
let error = XPCError.asXPCError(error: error, expectingOtherError: true)
self.errorHandler?(error)
self.replyWithErrorIfPossible(error, connection: connection, reply: &reply)
}
handleMessage(connection: connection, message: event)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nice 🧹

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧼

}
}
} else {
fatalError("Server handler for route \(request.route.pathComponents) cannot be run: \(handler)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you clarify this case?

By my reading, it happens if you have a XPCHandlerAsync registered, but don't pass the #available(macOS 10.15.0, *) check?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. It shouldn't be possible, but seemed prudent to explicitly fail if it was encountered. I can improve the error message?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just explain why it doesn't makes sense, along the lines of "The route wasn't an sync route, even that's the only kind that should be possible for this system"

self.handleError(error, expectingOtherError: true, connection: connection, reply: &reply)
}

} else if #available(macOS 10.15.0, *), let handler = handler as? XPCHandlerAsync {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh cool, I didn't know these compile-time and run-time checks could be combined into one statement like this. Neat!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's very convenient!

Comment on lines +322 to +325
// If a dictionary reply exists, then the message expects a reply to be sent back
if let reply = reply {
xpc_connection_send_message(connection, reply)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be moved out of this do block and after the catch below?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NVM, I think my bias towards minimal do blocks comes from Java/Ruby/etc. where any one of the lines can be the source of the thrown exception, and it's unclear which ones you intend to catch.

I have a new-found appreciation for try. It makes this so clear.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be moved out of this do block and after the catch below?

I know you said never mind, but FWIW it needs to be in the do block as it should only run if the handler succeeds. Otherwise both this logic and the logic in the catch block would run resulting in two sends to the same reply instance which would result in XPC API Misuse.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants