Sans-I/O Philosophy

What is Sans-I/O?

Sans-I/O is a design philosophy for developing network protocol libraries in which the library itself does not perform any I/O operations or asynchronous flow control. Instead, it provides interfaces for consuming and producing buffers of data and events. This simple change not only facilitates the development of such libraries but also enables their use with any asynchronous or synchronous I/O runtime.

Why a Sans-I/O approach?

Reusability

Developing high-quality network protocol libraries is a challenging and time-consuming task. Furthermore, most of the time, such libraries need extensive usage and feedback from users to refine their optimal API. Consequently, we aim to facilitate the reusability of such valuable code. A sans-I/O design approach eliminates all I/O-related code from the protocol stack. This enables the reuse of protocol stack code for developing I/O-based network libraries on top of different I/O runtimes.

Testability

Using a sans-I/O approach improves the testability of the library by enabling the creation of simpler, more reliable, and faster tests, as they no longer need to be written against I/O interfaces.

The following outlines how a sans-I/O approach can enhance the testability of a library implementation:

Lower cognitive complexity

One of the limiting factors in writing high-quality tests is the cognitive complexity of the tests themselves. For example, if we have to deal with I/O operations in the test, we have to set up connections, timers, and an I/O scheduler, which increases the cognitive load for both the writer and reader of the tests. Using a sans-I/O design approach eliminates all of these unnecessary setups and teardowns from the tests, making it possible to write simpler and cleaner tests.

The following example demonstrates the additional setup required in an I/O-coupled design:

echo_server es{log};
net::io_context ioc;
stream<test::stream> ws{ioc, fc};
ws.next_layer().connect(es.stream());
ws.handshake("localhost", "/");

Higher code coverage

Covering all the corner cases in network-facing libraries is limited by how we can actually reproduce them in the tests. We might not be able to reproduce some error conditions or create conditions for interleaving events to cover all the possibilities. A sans-I/O design eliminates the coupling to I/O interfaces and instead provides us with synchronous interfaces that we can use to test all possible combinations of events and data arrivals.

Faster execution times

Most of the time, I/O operations and system calls are the slowest part of a test. A sans-I/O design allows for writing tests with virtually no I/O operations or system calls at all. Consequently, we can execute thousands of tests within seconds, instead of minutes or even hours if we have to deal with real socket or pipe operations.

Moreover, testing time-sensitive logic with an I/O-coupled library requires realistic delays to cover all possibilities, which can quickly add up to a significant number (even if they are in orders of milliseconds individually). By using a sans-I/O approach, we can cover all of these combinations using synchronous interfaces, which require no delay at all.

The following example demonstrates the necessity of adding delays to tests for I/O-coupled functionalities:

es.async_ping();
test::run_for(ioc_, 500ms);
BEAST_EXPECT(!got_timeout);
test::run_for(ioc_, 750ms);
BEAST_EXPECT(got_timeout);

Deterministic test results

Relying on I/O operations and system calls can lead to flaky tests since we depend on resources and conditions controlled by the operating system. This issue becomes more pronounced when incorporating time-sensitive test cases, as they may fail due to the operating system scheduling other processes between tests or delaying network operations. A sans-I/O designed library can be thoroughly tested without any I/O operation, relying solely on simple function calls. In fact, a sans-I/O implementation resembles a giant state machine, allowing for deterministic and self-contained testing.

The following example demonstrates the ease of testing within a sans-I/O design, without any reliance on I/O operations or system calls:

response_parser pr(ctx);
pr.reset();
pr.start();
core::string_view in = "HTTP/1.1 200 OK\r\n"
                       "Content-Length: 3\r\n"
                       "\r\n"
                       "123";

while (! in.empty())
{
    auto n = buffers::buffer_copy(
        pr.prepare(),
        buffers::make_buffer(in));
    in.remove_prefix(n);
    pr.commit(n);
}
pr.commit_eof();

system::error_code ec;
pr.parse(ec);
BOOST_TEST(! ec.failed());
BOOST_TEST(pr.is_complete());
BOOST_TEST_EQ(pr.get().status(), status::ok);