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

Refactor stale promise consumption #1348

Draft
wants to merge 7 commits into
base: develop
Choose a base branch
from

Conversation

matthewrfennell
Copy link
Member

@matthewrfennell matthewrfennell commented Dec 28, 2024

This WIP PR contains refactors to allow an error popup on consumption of stale, rejected promises.

See matthewrfennell@3d8c5d2 for how I was thinking of implementing that error posting (based off this branch).

Feedback welcome, I've left as WIP as there are still some things I want to check (left comments for myself to review later)

This is so we can differentiate between fulfilled and rejected promises
when retrieving them from the DB.
This will be used to iterate over all promises in the DB on app
startup, showing a notification for any ones that were rejected.
This is used to keep track of the information we need to show an error
banner to the user if needed.
This is because, when we remove a stale promise from the DB, we are
effectively consuming it (i.e. removing it / marking it as not needed
anymore).
Previously, we had special handling for stale promises in
consumeStalePromises. That function manually reached into the DB and
removed them directly.

However, removing stale promises is really a special case of consuming
them - in both cases the intent is "this promise is no longer needed
and can be removed from everywhere we keep track of it" - the only
difference between stale and non-stale promises is that it is not
possible to perform the UI action in the stale case (although you may
want to perform some other action, such as showing an error popup).

Therefore: generalise attemptConsume to handle both stale and non-stale
promises - only calling the AnyPromise if the promise is stale, but
removing it from the DB in either case. As an extra bonus, remove the
removeAllPromises: method which was only used here.
@matthewrfennell matthewrfennell marked this pull request as draft December 28, 2024 23:57

@property(nonatomic, strong) AnyPromise* anyPromise;
@property(nonatomic, strong) id resolvedArgument;
@property(nonatomic, assign) BOOL isResolved;
@property(nonatomic, strong) MLPromiseRejection* rejection;
Copy link
Member Author

Choose a reason for hiding this comment

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

It doesn't feel very clean to have a dedicated MLPromiseRejection that will be nil in the vast majority of cases - consider creating a union to cleanly separate the fulfilled and rejected cases.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, a union or tagged enum would be a good fit here :)

@@ -136,26 +192,29 @@ -(void) attemptConsume
return;
}

if(!self.isResolved)
if(self.state == PromiseResolutionStateUnresolved)
Copy link
Member Author

Choose a reason for hiding this comment

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

We previously deleted all stale promises from the DB, but now they will only be deleted if they are resolved. I need to think if this is the right behaviour or not.

Copy link
Member

Choose a reason for hiding this comment

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

Problem is: this could still lead to stale promises: promises never resolved or rejected (for example all iq handler that don't have a matching invalidation handler that rejects the promise).

There are multiple solutions to this:

  1. Always implicitly pass a promise to the handler (maybe named _promise) and create a generic invalidation handler to be specified if no special invalidation is to be used. That generic invalidation would reject the promise given in the _promise paramter. The promise passed in could be auto-generated with some macro magic if no var named _promise is in scope when the $newHandler macro is used.
  2. Just silently remove unresolved promises from the db on app start without displaying an error. This way you'd have to write an explicit invalidation handler if you want to display an error on invalidation. No macro magic needed.

Case 2 has a downside, though: IQs that don't get resolved (or time out after 30 seconds, which automatically generates a fake IQ error response) before the XEP-0198 smacks session times out and a completely new xmpp session is created, won't generate any error. Even if the main app isn't killed (that'll leave the ui in a "spinning" state forever).
That downside even is a unresolved problem in our current codebase: The promise would neither be resolved nor rejected in that case. We'd have

I think solution 2 is better nonetheless.
We could fix this by:

  1. generate fake error IQs in the smacks error case, too.
  2. write invalidation handlers accompanying all iq handlers in our codebase (that are a lot)
  3. automatically call a generic invalidation handler if no invalidation handler was specified and that generic invalidatiuon handler could iterate over all handler arguments, pick out all MLPromises and reject them with some generic error message. That's possible, because calling an invalidation handler makes sure, the normal handler will never called in the future and thus all MLPromises are free to be resolved without any conflict (if not already resolved of course).

I prefer solution 3.

Copy link
Member

Choose a reason for hiding this comment

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

maybe I prefer solution 1 (together with 3?).


@end

@interface MLPromiseRejection : NSObject<NSSecureCoding>
Copy link
Member Author

Choose a reason for hiding this comment

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

I don't like exposing this in the header, but it's necessary to add the entry in HelperTools unserializeData:

Copy link
Member

Choose a reason for hiding this comment

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

Couldn't you just forward declare this with @class MLPromiseRejection; in HelperTools.m?

Copy link
Member Author

Choose a reason for hiding this comment

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

It isn't enough in this case unfortunately - I get this compile error in HelperTools.m

Receiver 'MLPromiseRejection' for class message is a forward declaration

Copy link
Member

Choose a reason for hiding this comment

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

Okay, then you could just add

@interface MLPromiseRejection : NSObject<NSSecureCoding>
@end

to the header and

@interface MLPromiseRejection() : NSObject<NSSecureCoding>
[...]
@end

containing all your actual property/method declarations to your MLPromise.m. That way you'll only expose the fact that this class exists, but none of its (internal) methods :)

Copy link
Member

@tmolitor-stud-tu tmolitor-stud-tu left a comment

Choose a reason for hiding this comment

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

Added lots of comments :)


@end

@interface MLPromiseRejection : NSObject<NSSecureCoding>
Copy link
Member

Choose a reason for hiding this comment

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

Couldn't you just forward declare this with @class MLPromiseRejection; in HelperTools.m?

PromiseResolutionStateRejected,
};

@interface MLPromiseRejection()
Copy link
Member

Choose a reason for hiding this comment

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

I'd add a readonly computed property returning the xmpp object for the stored accountID. Similar to the one in MLCall.m.


@property(nonatomic, strong) AnyPromise* anyPromise;
@property(nonatomic, strong) id resolvedArgument;
@property(nonatomic, assign) BOOL isResolved;
@property(nonatomic, strong) MLPromiseRejection* rejection;
Copy link
Member

Choose a reason for hiding this comment

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

Yes, a union or tagged enum would be a good fit here :)

}

-(void) reject:(NSError*) error
-(void) rejectWithError:(NSError*) error andNode:(XMPPStanza* _Nullable) node forAccountWithID:(NSNumber*) accountID
Copy link
Member

Choose a reason for hiding this comment

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

I think we should provide both a simple -(void) reject:(NSError*) error method and a more complicated -(void) rejectWithError:(NSError*) error andNode:(XMPPStanza* _Nullable) node forAccountWithID:(NSNumber*) accountID

The simple method could just set the accountID to nil or @0 and the HelperTools method would have to be modifed to use something like "Error" or "General Error" as title instead of the jid, if the accountID wan't provided.

@@ -136,26 +192,29 @@ -(void) attemptConsume
return;
}

if(!self.isResolved)
if(self.state == PromiseResolutionStateUnresolved)
Copy link
Member

Choose a reason for hiding this comment

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

Problem is: this could still lead to stale promises: promises never resolved or rejected (for example all iq handler that don't have a matching invalidation handler that rejects the promise).

There are multiple solutions to this:

  1. Always implicitly pass a promise to the handler (maybe named _promise) and create a generic invalidation handler to be specified if no special invalidation is to be used. That generic invalidation would reject the promise given in the _promise paramter. The promise passed in could be auto-generated with some macro magic if no var named _promise is in scope when the $newHandler macro is used.
  2. Just silently remove unresolved promises from the db on app start without displaying an error. This way you'd have to write an explicit invalidation handler if you want to display an error on invalidation. No macro magic needed.

Case 2 has a downside, though: IQs that don't get resolved (or time out after 30 seconds, which automatically generates a fake IQ error response) before the XEP-0198 smacks session times out and a completely new xmpp session is created, won't generate any error. Even if the main app isn't killed (that'll leave the ui in a "spinning" state forever).
That downside even is a unresolved problem in our current codebase: The promise would neither be resolved nor rejected in that case. We'd have

I think solution 2 is better nonetheless.
We could fix this by:

  1. generate fake error IQs in the smacks error case, too.
  2. write invalidation handlers accompanying all iq handlers in our codebase (that are a lot)
  3. automatically call a generic invalidation handler if no invalidation handler was specified and that generic invalidatiuon handler could iterate over all handler arguments, pick out all MLPromises and reject them with some generic error message. That's possible, because calling an invalidation handler makes sure, the normal handler will never called in the future and thus all MLPromises are free to be resolved without any conflict (if not already resolved of course).

I prefer solution 3.

[_resolvers removeObjectForKey:self.uuid];
DDLogVerbose(@"Removed resolver with uuid %@ from resolvers map", self.uuid);
DDLogVerbose(@"Resolvers map is now: %@", _resolvers);
}

[[DataLayer sharedInstance] removePromise:self];
Copy link
Member

Choose a reason for hiding this comment

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

I talked about removing all promises from the db (even stale ones) in some comment above and now realize, that already happens :)

@@ -395,7 +395,7 @@ -(BOOL) application:(UIApplication*) application willFinishLaunchingWithOptions:
});

// Remove stale promises left in the DB that weren't consumed last time we ran the app
[MLPromise removeStalePromises];
[MLPromise consumeStalePromises];
Copy link
Member

Choose a reason for hiding this comment

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

I think we'll have to extract a list/set of all pending stale promises after making sure the appex is not running anymore (the last act in the will launch method), but consume these stale promises only after creating all xmpp objects later in the app startup cycle.

We must extract all stale promises that early in the app startup cycle to not introduce race conditions with the startup process that could introduce new MLPromises that aren't stale before or while all xmpp objects are created.

Copy link
Member

Choose a reason for hiding this comment

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

I'd not wait for lissine and implement it in such a way, that all accountIDs, for which no enabled account (and thus an xmpp object) could be found, simply show a generic error title instead of the jid (see some comment above). That can simply improved to always show the jid later on after lissine's rewrite.

But even then not giving an xmpp object and using a generic notification title for the error notification in this case would be a useful feature I'd like to have independent of our MLPromise framework.

@tmolitor-stud-tu tmolitor-stud-tu force-pushed the develop branch 20 times, most recently from b9b6827 to 418c3b9 Compare January 3, 2025 19:00
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.

2 participants