Day 19: Hurry up and wait
Change agent
An early note on naming: previously I referred to the component of the World architecture that contained the pure execution state machine and produced actions as “ExecutionState”. This name was taken from The plan-execute pattern. It works well enough, but it’s fairly long, mechanical, and lacks the poetry I’m aiming for with other aspects of the project. After reflecting on it, I’ve decided to rename it to “Agent”, which I think perfectly represents it. I hesitated because that term is being swallowed up by the AI hype machine, but in retrospect that would be preemptively admitting defeat.
The waiting game
Now that I have started implementing the I/O driver, I have had to start considering I/O design. In particular, a goal I have for this project is to avoid busy-waiting or polling entirely. In other words, I never want Séance to be uselessly waking up and checking to see if some pending I/O is ready yet. If we can’t make any progress until some I/O event completes, then we should be using 0% of the CPU.
The modern way to avoid busy-waiting is the reactor pattern, where you create some OS-provided structure that outlines all the I/O you are waiting on, submit it with some system call, and the OS puts your process to sleep until it has something for you. The underlying system calls that do this are kqueue on macOS or BSD and either epoll or io_uring on Linux. There is also an equivalent on Windows, but I’ve never used it. Libraries like Tokio expose abstractions over the backing OS-specific logic, making it easier to build cross-platform software.
Each iteration of the main loop comes in a few select steps: first, the driver sends its pending message to the agent, which in turn sends actions into the world. The driver then adapts those actions into some underlying I/O system, in this case Tokio. Then we call into that I/O system, waiting (potentially indefinitely) until some I/O has been completed.
This makes it impossible for us to busy-wait, because the loop doesn’t continue until some I/O has completed. However, it does currently come with the risk of sleeping forever: if the agent doesn’t perform any actions in response to a message (including eg. exiting the loop) then we will wait forever. I plan to fix this by keeping track of how many I/O operations are currently pending, and exiting the program if it is zero when we enter the waiting phase.
Plucking at the web
The primary actual code I contributed in this session was to start fetching the RSS feeds once we have loaded the config:
for (feed_index, feed) in config.feeds().enumerate() {
// TODO: Communicate somehow that the feed config is corrupt
let Ok(feed) = feed else { continue };
world.act(Action::HttpGet(HttpGetId::Feed(feed_index as u16), feed.url.clone()));
}
I don’t yet respond when the responses actually come back, but that’s the next step.
The biggest remaining unknown is how I represent errors. Unlike with file reading, where I could just
reuse the standard library’s io::ErrorKind type, I will need to model this myself.
In the longer term, I would also like to split the HTTP responses between multiple messages, reflecting each of the stages we need to go through during the network operation (DNS, TCP connections, etc). This is an extension of the visibility of system status principle I am adopting for the project.