Day 15: Failing to behave

After a few posts expositing on big ideas, today was a short series of details. I’ve made a bit more progress extending the world implementation to support config loading, and learned some minutiae about Rust’s I/O errors.

First up, the code, second up, its discussion.

impl ExecutionState
    fn new() -> ExecutionState {
        ExecutionState {
            file_read_ids: FileReadIdCounter::new(),
            config_state: ConfigState::Initial
        }
    }

    fn init(&mut self, mut world: impl World) {
        let config_read_id = self.file_read_ids.next();
        world.act(Action::FileRead(config_read_id, AppPath::Config));
        self.config_state = ConfigState::Pending(config_read_id);
    }


    fn receive(&mut self, message: Message, mut world: impl World) {
        if let ConfigState::Pending(config_id) = self.config_state {
            match message {
                Message::FileReadComplete(read_id, read_result) if read_id == config_id => {
                    let Ok(read_data) = read_result else {
                        // TODO: handle gracefully. Retry some error types? Also need to create it
                        // if doesn't exist.
                        world.act(Action::ExitFailure("failed to read config"));
                        return
                    };

                    let Ok(config_str) = str::from_utf8(&read_data) else {
                        world.act(Action::ExitFailure("config is not valid utf-8"));
                        return
                    };

                    let Ok(config) = Config::parse(config_str) else {
                        // TODO: Present pretty miette parse error here
                        world.act(Action::ExitFailure("config is invalid"));
                        return
                    };

                    self.config_state = ConfigState::Loaded(config);
                },
                other => eprintln!("warning: unexpected message {other:?}")
            }
        }
    }
}

A note on proper behaviour

There is very little that Séance can do to synchronize before the config is loaded. The config contains, among other things, the list of feeds. This means that our first action is to request that a file be read at the config path, and our first branch in receive handles when the config has not yet been read.

I suspect that there will be two high-level phases for the “sync” command process: config loading and feed synchronization. The later phase will contain the majority of the application logic, but totally depends on the former. This gives us to clear “modes” of behaviour, with very little shared logic, which will each need their own section in receive(). Currently I am using the self.config_state value to choose which mode we are currently in.

This modal message handling system reminds me of the one implemented in Goblins by Spritely. I am far from an expert, but as I understand it, each “goblin” (similar to an actor) has at any time a single behaviour, which is a procedure that runs on the next incoming message. I believe this is closer to the original formulation of the Actor model.

Similarly, I could refactor ExecutionState into an enum whose variants represent the different behaviours of the system. For cleanliness, each variant could contain a single type that also implements receive, with the top level effectively just routing to the correct implementation for the current state.

I am avoiding adding any more patterns or abstractions at this point, though, because the system is still nascent — I don’t yet know how it wants to grow or what it needs to do. Too often I’ve found myself writing out elegant abstractions that solve the wrong problem, so now I prefer to let the contours of the problem space reveal themselves vividly before I try to design around them.

Why isn’t io::Error Clone?

One tangential change I made today was in the definition of Action::FileReadComplete

#[derive(Debug, Clone, PartialEq, Eq)]
enum Message {
    FileReadComplete(FileReadId, Result<Vec<u8>, io::ErrorKind>)
    //                                           ~~~~~~~~~~~~~~  <-- new!
}

Previously this was instead an io::Result<Vec<u8>>, which is an alias for Result<Vec<u8>, io::Error>. However, because I want Message and Action to feel like pure data objects, akin to, say, Strings (ideally u32s, but the need for interior strings prevents that), I want them to be Clone. However, io::Error doesn’t implement Clone! This surprised me, but that’s because io::Error has a more complex interior than I expected. I am used to thinking of io::Result as basically only including an io::ErrorKind, because that’s what you normally interact with, but you can actually create your own io::Errors with an arbitrary interior type using io::Error::new. This requires providing something that is Into<Box<dyn Error + Send + Sync>>, which you may note doesn’t implement Clone.

To work around this, I just store the io::ErrorKind directly, since I don’t expect to need any more than that.