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

[RFC] Remove UnifiedMessages reflection usage #1433

Closed
wants to merge 40 commits into from

Conversation

affederaffe
Copy link
Contributor

This PR tries to remove the last directly existing reflection usage in SteamUnifiedMessages.
The protobuf code generator was updated instead to directly handle the messaging.
Obviously this is a breaking change, but, at least in my opinion, necessary in order to fully remove reflection.

@xPaw
Copy link
Member

xPaw commented Sep 18, 2024

I like the idea. If you enable the trimming warnings, what warnings are reported? In particular handling of incoming messages.

Do note that protobuf.net is heavily reflection based, so that doesn't help us with AOT/trimming atm.

@affederaffe
Copy link
Contributor Author

I like the idea. If you enable the trimming warnings, what warnings are reported? In particular handling of incoming messages.

Do note that protobuf.net is heavily reflection based, so that doesn't help us with AOT/trimming atm.

There seem to be no more trimming warnings aside from protobuf.net usage. I'll try to see how to avoid these too soon ™️

@xPaw
Copy link
Member

xPaw commented Sep 18, 2024

@yaakov-h was also looking at that, and arrived at the conclusion that switching to google.protobuf is probably the way to go.

@xPaw
Copy link
Member

xPaw commented Sep 18, 2024

I don't however like calling CanHandleMsg for every single interface, maybe registration and a dictionary lookup would work better.

See 807fa51 for previous attempt at that.

@affederaffe
Copy link
Contributor Author

I don't however like calling CanHandleMsg for every single interface, maybe registration and a dictionary lookup would work better.

See 807fa51 for previous attempt at that.

It's not really every interface, only for currently created services (until they are disposed that is).
A dictionary could actually be worse for memory usage I imagine, but that'd need testing

@xPaw
Copy link
Member

xPaw commented Sep 18, 2024

Right I see it only goes through the registered services. But it calls CanHandleMsg for every service so it does the parsing for every one of these.

The handlers list could be a dictionary of <service name, handler>, so that you can parse the method name, and directly check if a handler is registered for a particular service.

@yaakov-h
Copy link
Member

This is a very different approach to the one I was looking at, and I do like it.

Keep in mind that consumers should be able to generate and run additional services (e.g. from updated protobufs) so some of those internal methods would need to be public.

Regarding the discussion above, perhaps each service should expose its name as a public property, then we can parse the service name once and invoke any matching handlers?

However, yes, for full trimming support and AOT we will almost certainly need to switch to Google.Protobuf. I haven't spiked AOT w/ Google just yet, but even manually hand-writing serializers and deserializers with protobuf-net.Core triggered trim and AOT warnings because it is annoyingly dependent on dynamic coding APIs.

@xPaw
Copy link
Member

xPaw commented Sep 19, 2024

perhaps each service should expose its name as a public property

Could add service name to UnifiedService, and then have HandleMsg( method, version, packet ) and then only call HandleMsg if the service name matches.

@xPaw
Copy link
Member

xPaw commented Sep 20, 2024

FYI this PR needs to rebase the protobufs submodule because its using an older commit.

@affederaffe
Copy link
Contributor Author

This is a very different approach to the one I was looking at, and I do like it.

Keep in mind that consumers should be able to generate and run additional services (e.g. from updated protobufs) so some of those internal methods would need to be public.

Regarding the discussion above, perhaps each service should expose its name as a public property, then we can parse the service name once and invoke any matching handlers?

However, yes, for full trimming support and AOT we will almost certainly need to switch to Google.Protobuf. I haven't spiked AOT w/ Google just yet, but even manually hand-writing serializers and deserializers with protobuf-net.Core triggered trim and AOT warnings because it is annoyingly dependent on dynamic coding APIs.

From what I could gather, there currently is no satisfying solution:

  1. Google.Protobuf with protoc ignores services completely, maybe we could modify the c# generator plugin
  2. Build an AOT-compatible generator on top of protobuf-net's CommonCodeGenerator/CSharpCodeGenerator
    2.1. For Google.Protobuf: would be completely AOT-compatible, although slightly cursed
    2.2. For protobuf-net: less cursed, though even the protobuf-net.Core library seems to use reflection to some extend
  3. (A completely home-brew solution?)

This is, however, out of scope of what I currently have time for.
Per https://protobuf-net.github.io/protobuf-net/3_0, there seems to be some intention to create a source generator, but I wouldn't bet it releases any time soon

@xPaw
Copy link
Member

xPaw commented Sep 26, 2024

Fix the build error on ci please. Also rebase the commit on master, so that the protobufs submodule does not change (to remove the unrelated changes).

@xPaw
Copy link
Member

xPaw commented Sep 26, 2024

Breaking change for consumers:

old:

SteamUnifiedMessages.UnifiedService<ISomething> service = unifiedMessages.CreateService<ISomething>();
SteamUnifiedMessages.ServiceMethodResponse job = await service.SendMessage(x => x.DoThing(msg));
SomeResponse response = job.GetDeserializedResponse<SomeResponse>();

new:

Something service = unifiedMessages.CreateService<Something>();
SteamUnifiedMessages.ServiceMsg<SomeResponse> job = await service.DoThing(msg);
SomeResponse response = job.PacketResult;

PacketResult is an odd name, and I think ServiceMsg also needs some better name for responses.

Overall this is good because we no longer need to provide the correct type into GetDeserializedResponse.

One slight worry I have is that services directly return the service, which is an extension of UnifiedService instead of being a wrapper like UnifiedService<Something>

@xPaw
Copy link
Member

xPaw commented Sep 26, 2024

I just noticed that SendNotification is not being generated, for example RemoteClient.NotifyOnline#1. I think we can do it when response type is NoResponse.

@affederaffe
Copy link
Contributor Author

One slight worry I have is that services directly return the service, which is an extension of UnifiedService instead of being a wrapper like UnifiedService<Something>

I think this would be rather complicated to achieve since we'd need some form of mapping between the interface and implementation.
Since there will be another breaking change when switching to Google.Protobuf anyway and I don't think the public api of the generated classes will change until then, this is good enough.

# Conflicts:
#	Resources/Protobufs
#	SteamKit2/SteamKit2/Base/Generated/SteamMsgAuth.cs
#	SteamKit2/SteamKit2/Base/Generated/SteamMsgStoreBrowse.cs
#	SteamKit2/SteamKit2/Base/Generated/WebUI/SteamMsgAccountCart.cs
#	SteamKit2/SteamKit2/Base/Generated/WebUI/SteamMsgCommon.cs
@xPaw
Copy link
Member

xPaw commented Oct 13, 2024

Just a thought I had, calling CreateService multiple times like in d9f6b1b might be problematic, as it returns a disposable service, and the handler is being added by the name.

Wouldn't it break if you called CreateService multiple times? Maybe to simplify services should be non disposable and it should return an existing one (handlers.GetOrAdd basically).

@JustArchi
Copy link
Contributor

JustArchi commented Oct 13, 2024

This piece of code is not working (callback function handler not being called despite event arriving):

CallbackManager.Subscribe<SteamUnifiedMessages.ServiceMethodNotification<CChatRoom_IncomingChatMessage_Notification>>(OnIncomingChatMessage);

CallbackManager.Subscribe<SteamUnifiedMessages.ServiceMethodNotification<CFriendMessages_IncomingMessage_Notification>>(OnIncomingMessage);

Previously I had this and it worked properly:

CallbackManager.Subscribe<SteamUnifiedMessages.ServiceMethodNotification>(OnServiceMethod);

private async void OnServiceMethod(SteamUnifiedMessages.ServiceMethodNotification notification) {
		ArgumentNullException.ThrowIfNull(notification);

		switch (notification.MethodName) {
			case "ChatRoomClient.NotifyIncomingChatMessage#1":
				await OnIncomingChatMessage((CChatRoom_IncomingChatMessage_Notification) notification.Body).ConfigureAwait(false);

				break;
			case "FriendMessagesClient.IncomingMessage#1":
				await OnIncomingMessage((CFriendMessages_IncomingMessage_Notification) notification.Body).ConfigureAwait(false);

				break;
		}
	}

My experimental code lies in https://github.com/JustArchiNET/ArchiSteamFarm/tree/unified-experiments, the diff with main is JustArchiNET/ArchiSteamFarm@main...unified-experiments. You can also get artifacts for testing in https://github.com/JustArchiNET/ArchiSteamFarm/actions/workflows/publish.yml?query=branch%3Aunified-experiments. If I did something wrong, let me know and I can give it another try.

@affederaffe
Copy link
Contributor Author

Just a thought I had, calling CreateService multiple times like in d9f6b1b might be problematic, as it returns a disposable service, and the handler is being added by the name.

Wouldn't it break if you called CreateService multiple times? Maybe to simplify services should be non disposable and it should return an existing one (handlers.GetOrAdd basically).

I agree that using a single instance is probably the way to go, but it should still be disposable in order to be removed from _handlers.

@xPaw
Copy link
Member

xPaw commented Oct 13, 2024

but it should still be disposable

This could be achieved by adding a separate call to remove it. I think that's more explicit than leaving it disposable (because if you call CreateService for some temporary call, disposing it would be dispose it for everything).

@affederaffe
Copy link
Contributor Author

This piece of code is not working (callback function handler not being called despite event arriving):

CallbackManager.Subscribe<SteamUnifiedMessages.ServiceMethodNotification<CChatRoom_IncomingChatMessage_Notification>>(OnIncomingChatMessage);

CallbackManager.Subscribe<SteamUnifiedMessages.ServiceMethodNotification<CFriendMessages_IncomingMessage_Notification>>(OnIncomingMessage);

Previously I had this and it worked properly:

CallbackManager.Subscribe<SteamUnifiedMessages.ServiceMethodNotification>(OnServiceMethod);

private async void OnServiceMethod(SteamUnifiedMessages.ServiceMethodNotification notification) {
		ArgumentNullException.ThrowIfNull(notification);

		switch (notification.MethodName) {
			case "ChatRoomClient.NotifyIncomingChatMessage#1":
				await OnIncomingChatMessage((CChatRoom_IncomingChatMessage_Notification) notification.Body).ConfigureAwait(false);

				break;
			case "FriendMessagesClient.IncomingMessage#1":
				await OnIncomingMessage((CFriendMessages_IncomingMessage_Notification) notification.Body).ConfigureAwait(false);

				break;
		}
	}

My experimental code lies in https://github.com/JustArchiNET/ArchiSteamFarm/tree/unified-experiments, the diff with main is JustArchiNET/[email protected]. You can also get artifacts for testing in https://github.com/JustArchiNET/ArchiSteamFarm/actions/workflows/publish.yml?query=branch%3Aunified-experiments. If I did something wrong, let me know and I can give it another try.

You need to create the service the notification is coming from first with SteamUnifiedMessages.CreateService as it is used to map the notification method name to the result type internally.

@xPaw
Copy link
Member

xPaw commented Oct 13, 2024

You need to create the service the notification is coming from first

He does in ArchiHandler.

@affederaffe
Copy link
Contributor Author

You need to create the service the notification is coming from first

He does in ArchiHandler.

ChatRoomClient and FriendMessagesClient are missing, which is why the message cannot be dispatched; there's no handler registered for it.

@xPaw
Copy link
Member

xPaw commented Oct 13, 2024

My bad, missed the "Client" part.

This is outside of the scope of this PR, maybe a future improvement: Subscribing to incoming notifications could be done through CreateService so that you couldn't mess this up.

EDIT: Can we add the sample of receiving a notification like this to the unified messages sample? So that it is documented that you need create a service to receive stuff. FriendMessagesClient.IncomingMessage should be easily testable. Sorry for a lot of back-and-forth on this PR, just want to minimize any pain for consumers.

@xPaw
Copy link
Member

xPaw commented Oct 14, 2024

One solution I've come up to solve the handler not being registered is this:

  1. mgr.Subscribe<ChatRoomClient, CChatRoom_IncomingChatMessage_Notification>(func)
  2. ChatRoomClient.Subscribe<CChatRoom_IncomingChatMessage_Notification>(mgr, func)

The first option makes sense because the callbacks still go through the manager, and probably preferrable to keep the logic in same place. It probably needs a separate method name, like SubscribeServiceMethodResponse and ServiceMethodNotification.
The second option makes sense because you are subscribing on a specific service (but you still have to give it a manager).

However this still doesn't prevent users from shooting themselves in the foot because you still can register <WrongService, DifferentMethod>, as that would have to be validated somehow (generate another method to check? but that seems overkill, maybe at that point its not our problem anymore).

And mgr.Subscribe would have to disallow subscribing directly to ServiceMethodNotification.

Thoughts? Any different ideas?

P.S. See my comment above about adding a sample for receiving notifications.

@JustArchi
Copy link
Contributor

You need to create the service the notification is coming from first

He does in ArchiHandler.

ChatRoomClient and FriendMessagesClient are missing, which is why the message cannot be dispatched; there's no handler registered for it.

Thanks, I can confirm that it works after adding required clients.

The only outstanding issue I see with this PR is that right now, it's possible to register for callback but skip registration of the service.

    mgr.Subscribe<ChatRoomClient, CChatRoom_IncomingChatMessage_Notification>(func)
    ChatRoomClient.Subscribe<CChatRoom_IncomingChatMessage_Notification>(mgr, func)

As @xPaw suggested above, any of those two would fix it. If you asked me, I like first one slightly more.

@affederaffe
Copy link
Contributor Author

One solution I've come up to solve the handler not being registered is this:

1. `mgr.Subscribe<ChatRoomClient, CChatRoom_IncomingChatMessage_Notification>(func)`

2. `ChatRoomClient.Subscribe<CChatRoom_IncomingChatMessage_Notification>(mgr, func)`

The first option makes sense because the callbacks still go through the manager, and probably preferrable to keep the logic in same place. It probably needs a separate method name, like SubscribeServiceMethodResponse and ServiceMethodNotification. The second option makes sense because you are subscribing on a specific service (but you still have to give it a manager).

However this still doesn't prevent users from shooting themselves in the foot because you still can register <WrongService, DifferentMethod>, as that would have to be validated somehow (generate another method to check? but that seems overkill, maybe at that point its not our problem anymore).

And mgr.Subscribe would have to disallow subscribing directly to ServiceMethodNotification.

Thoughts? Any different ideas?

P.S. See my comment above about adding a sample for receiving notifications.

The approach to subscribe to the manager directly seems more in line with what users currently do and therefore what is probably expected.
Additionally mapping types to method return types seems overkill to me, imo just documenting the behaviour is enough.
Disallowing ServiceMethodNotification<T> or ServiceMethodResponse<T> should be doable with type constraints.

I'll try to implement this soon and add/update the samples

Copy link
Member

@voided voided left a comment

Choose a reason for hiding this comment

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

This looks really good!

I agree with having that one-line approach of subscribing to notifications via the callback manager - infinitely less foot-guns than needing to remember to create a service.

I'll defer to @xPaw for the final approval, feel free to consider my comments nit-picky. :)

/// </summary>
public partial class SteamUnifiedMessages : ClientMsgHandler
{
private readonly ConcurrentDictionary<string, object> _handlers = [ ];
Copy link
Member

Choose a reason for hiding this comment

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

Can this be ConcurrentDictionary<string, IUnifiedService> to avoid the various casts/as to TService?

Seems like all the type constraints are always IUnifiedService.

break;
}
return ( _handlers.GetOrAdd( TService.ServiceName, static ( _, unifiedMessages ) =>
new TService { UnifiedMessages = unifiedMessages }, this ) as TService )!;
Copy link
Member

Choose a reason for hiding this comment

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

Could cast directly to TService rather than as - that should avoid the extra null assertion operator (or whatever ! is called).

public static string ServiceName { get; } = "Workshop";

/// <inheritdoc />
public SteamUnifiedMessages UnifiedMessages { get; init; }
Copy link
Member

Choose a reason for hiding this comment

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

Leaving this comment for this service, but this applies to how the generator created all these.

Feels like this should be internal rather than public. A consumer could either interact with the service methods directly, or grab the handler from the SteamClient.

There's lots of pre-existing undefined behavior that I unfortunately designed into the callback manager, but it makes me think about (edge) cases like RemoveServiceing a service but then still holding on to it for this UnifiedMessages property.

Copy link
Member

Choose a reason for hiding this comment

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

What if a consumer wants to generate and use their own service?

Copy link
Member

Choose a reason for hiding this comment

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

Good point. I think an explicit interface implementation can solve for that: https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/interfaces/explicit-interface-implementation

It would hide access to the property on the implementation types and only allow access if you cast to a IUnifiedService, which the SteamUnifiedMessages handler can easily do.

A consumer could end up doing the same, but it would at least make it more unlikely.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah it would be good to get rid of this property from the generated classes, and only leave it on the parent.

/// </summary>
/// <param name="callbackFunc">The function to invoke with the callback.</param>
/// <returns>An <see cref="IDisposable"/>. Disposing of the return value will unsubscribe the <paramref name="callbackFunc"/>.</returns>
public IDisposable SubscribeNotifications<TService, TNotification>( Action<SteamUnifiedMessages.ServiceMethodNotification<TNotification>> callbackFunc )
Copy link
Member

Choose a reason for hiding this comment

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

SubscribeServiceNotification perhaps?

Copy link
Contributor

@JustArchi JustArchi left a comment

Choose a reason for hiding this comment

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

Works for me, summary of all changes I did: JustArchiNET/ArchiSteamFarm@main...unified-experiments

@xPaw xPaw closed this in 2ba0a55 Oct 21, 2024
@xPaw
Copy link
Member

xPaw commented Oct 21, 2024

@voided's comments were caused by making servicename a static, I reverted that. Squashed all the commits with minor cleanup and merged in 2ba0a55

Thanks!

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

Successfully merging this pull request may close these issues.

6 participants