MegaAntiCheat / client-backend

GNU General Public License v3.0
118 stars 25 forks source link

Re-engineer event loop and enforce better design principles and paradigms #110

Closed lili7h closed 7 months ago

lili7h commented 7 months ago

@Bash-09 and myself have been theory crafting and discussing a way to re-engineer the core event loop of the backend into a more distinct and well-structured paradigm that enforces the various principles we want to enforce around server state manipulation and implementation design.

We both have our own ideas and opinions for what a core event loop should do, and this ticket will be the place to discuss and plan.

We both think that the core event loop should be composable and distinctly modifiable by developers importing our crate, and that it should also be built by the rustic builder paradigm and house a self-contained chunk of infrastructure that does a majority of the boilerplate work.

Bash-09 commented 7 months ago

The architecture I'm proposing here was inspired by the architecture that Lilith initially suggested, but I've tried to simplify it considerably and work it into something that can be implemented without too much effort and hopefully fairly idiomatically in Rust.

Here is a diagram roughly showing what the control flow for this suggestion might look like when fully implemented to the same feature set that MAC currently has (and slightly more).

image

Example and API

A demonstration of the interface is shown below, and this prototype also exists in-full on the Bash/EventLoopExperiment branch.


// Define the messages and handlers in a macro like so:
// `define_messages!(MessageEnumName<StateType>: Message1, Message2, Message3, ...);`
// `define_handlers!(HandlerEnumName<StateType, MessageEnumName>: Handler1, Handler2, ...);`
//
// These will create an enum for each of the variants of messages and handlers that were provided, and implements a handful of boilerplate for you. Ending up with essentially:
// enum MessageEnumName {
//    Message1(Message1),
//    Message2(Message2),
//    Message3(Message3),
// }
// 
// impl From<Message1> for MessageEnumName {...}
// impl From<Message2> for MessageEnumName {...}
// ...
//
define_messages!(Message<State>: Refresh, NewPlayer, ProfileLookup);
define_handlers!(Handler<State, Message>: GetNewPlayers, LookupProfiles);

pub struct State {}

// Handlers ***************
// Handlers are defined as structs which implement the HandlerStruct<S, M> trait.
// Each cycle of the event loop, `handle_message` is called with each of the messages so it can pattern match and decide to handle it or not.
// The returned Handled<M> type can consist of zero (Handled::none()), one (Handled::single(m)), or multiple 
// (Handled::multiple(iter m) command, which can be straight messages or Futures resolving to new messages, which will 
// eventually be emitted back into the event loop.
//
// Furthermore, handlers can hold their own state, allowing for batching of results (e.g. steam profile lookups can be accumulated,
// and when the batch fills up enough or time has passed they can all be sent at once. This may require adding a message source
// such as a timer to regularly allow the handler a chance to dispatch) or just to store persistent data like API keys.

pub struct GetNewPlayers;
impl HandlerStruct<State, Message> for GetNewPlayers {
    fn handle_message(&mut self, state: &State, message: &Message) -> Handled<Message> {
        match message {
            Message::Refresh(_) => Handled::single(NewPlayer {}),
            _ => Handled::none(),
        }
    }
}

pub struct LookupProfiles {
    pub api_key: Arc<str>,
}
impl HandlerStruct<State, Message> for LookupProfiles {
    fn handle_message(&mut self, state: &State, message: &Message) -> Handled<Message> {
        match message {
            // On new players, send a SteamAPI request for the player's profile
            Message::NewPlayer(_) => {
                let key = self.api_key.clone();
                Handled::future(async move {
                    let _ = SteamAPI::new(key)
                        .get()
                        .ISteamUser()
                        .GetPlayerSummaries(vec![String::from("")])
                        .execute()
                        .await;

                    Message::ProfileLookup(ProfileLookup {})
                })
            }
            _ => Handled::none(),
        }
    }
}

// Messages **************
// Messages are defined as structs which implement the StateUpdater<S> trait.
// After entering the event loop queue, the next cycle will pass the message through all of the handlers, and then eventually `update_state` will be called, giving it a chance to make any changes to the state it needs. The default implementation for `update_state` is a noop, so specifying `impl StateUpdater<State> for MessageVariant {}` without actually defining the function inside will just mean the message does not modify the state.

pub struct Refresh;
impl StateUpdater<State> for Refresh {
    fn update_state(&self, state: &mut State) {
        println!("Refreshing");
    }
}

pub struct NewPlayer {}
impl StateUpdater<State> for NewPlayer {}

pub struct ProfileLookup {}
impl StateUpdater<State> for ProfileLookup {
    fn update_state(&self, state: &mut State) {
        println!("Got profile result!");
    }
}

// Main **********************

fn main() {
    let (tx, rx) = std::sync::mpsc::channel();

    // The event loop itself can be easily constructed with the builder pattern, by creating a new one and appending the different sources and handlers to it. 
    let mut event_loop: EventLoop<State, Message, Handler> = EventLoop::new()
        .add_source(Box::new(rx))
        .add_handler(GetNewPlayers {})
        .add_handler(LookupProfiles {
            api_key: "boop".into(),
        });

    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async move {
            // Refresh loop
            tokio::task::spawn(async move {
                let mut refresh_interval = tokio::time::interval(Duration::from_secs(3));
                refresh_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
                loop {
                    refresh_interval.tick().await;
                    tx.send(Refresh {}).unwrap();
                }
            });

            // Run event loop
            let mut state = State {};

            loop {
                // Call execute_cycle will resolve one cycle of the event loop. In a single cycle:
                // - Any futures returned from handlers are polled, adding finished ones to the message queue
                // - All queued messages are passed through the handlers and used to update the state
                // - All resulting messages from the handlers are added to the message queue for next cycle, and futures are dispatched for a later cycle
                event_loop.execute_cycle(&mut state).await;
            }
        });
}

The above example is very simple, but attempts to demonstrate that the behaviour we want to get out of the event loop can be separated with a clean and user-friendly API.

The flow graph for this example is as shown:

image

Running this example provides the output below:

     Running `target/debug/client_backend`
Refreshing
Got profile result!
Refreshing
Got profile result!
Refreshing
Got profile result!
Refreshing
Got profile result!
Refreshing
Got profile result!

Further concerns and considerations + RFC

If anybody has anything they would like to add to this, please feel free to share your ideas/suggestions/concerns/whatever here or ping me in the MSB discord server. For now I will continue investigating ways to solve any remaining issues with this implementation, and potentially implementing the necessary components to get this closer to replacing the nasty spaghetti we've got currently.

Bash-09 commented 7 months ago

Done in #114