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.