EMILUA(7)
๐ฆ NAME ๐ฆ
Emilua - Lua execution engine. This is the EMILUA(7) page from the Emilua reference, and bestie, weโre getting into it ๐
โจ INTRODUCTION โจ
Streams are one of the fundamental concepts you have to deal with when youโre working on IO. Think of a stream as a channel where data flows as slices of bytes, and those slices respect certain properties (e.g. ordering).
Emilua exposes two concepts to work with streams. Write streams are objects that implement the method write_some():
write_some(self, buffer: byte_span) โ integer
Writes buffer into the stream and returns the number of bytes written.
Similarly, read streams are objects that implement the method read_some():
read_some(self, buffer: byte_span) โ integer
Reads into buffer and returns the number of bytes read.
Exceptions are used to communicate errors, periodt.
When the type of the stream is not informed (i.e. read or write), itโs safe to assume the stream object implements both interfaces. Pipes are unidirectional, and separate classes exist to deal with each. On the other hand, TCP sockets are bidirectional and data can flow from any direction. Furthermore, many sockets allow one to shutdown one communication end so they can work unidirectionally as well.
๐ธ SHORT READS AND SHORT WRITES ๐ธ
Streams represent streams of bytes, with no implied message boundaries.
Each operation on a stream roughly maps to a single syscall[1], and it may transfer fewer bytes than requested. This is referred to as a short read or short write.
Reasons why short writes occur include out of buffer space in kernels that donโt expose proactors. The rationale for short reads is more obvious, and it should stay as an exercise for the reader (no pun intended).
To recover from short reads and short writes, one just has to try the operation again adjusting the buffer offsets. For instance, to fully drain the buffer for a write operation:
while #buffer > 0 do
local nwritten = stream:write_some(buffer)
buffer = buffer:sub(1 + nwritten)
end
The stream module already contains many of such algorithms. You may come up with your own algorithms as well taking the business rules of your application into consideration (e.g. combining newly arrived data into the next calls to write_some()). Alternatively, if you donโt need portable code, and the underlying system offers extra guarantees, you may do away with some of this complexity.
๐ง LAYERING ๐ง
Streams of bytes by themselves are hardly useful for application developers. Many patterns exist to have structured data on top:
- Fixed-length records (binary protocols).
- Fixed-length header + variably-sized data payload (binary protocols).
- Records delimited by certain character sequences (textual protocols).
- Combinations of the above (e.g. HTTP starts with a textual protocol of CRLF-delimited fields, and it might change to a fixed-length payload to read the body, and maybe change yet again to a textual protocol to extract the resulting JSON data).
Given a single protocol might require multiple strategies, itโs important to offer algorithms that donโt monopolize the stream object to themselves. The algorithms should be composable. The algorithms found in the stream module follow this guideline.
This composition of algorithms naturally build layers:
- Raw IO. The IO interfaces exposed by the OS. Thereโs no interface for peeking data or putting data back. Once the data is extracted out of the stream, itโs your responsibility to save it until needed.
- Buffered IO. Just as short reads might happen, so can "long" reads. Upon dispatching the message for processing that includes data until the delimiter, you must be careful to not discard extra data that represents the start of the next message. Buffered IO is built on top of raw IO by managing an user-space buffer (and an associated index for the current message) alongside with the IO object.
- Formatted IO. Built on top of buffered IO integrating a parser (for input), and/or a generator (for output). Now the user is no longer interacting with slices of bytes, but properly structured data and messages.
Itโs always easier to work with high-level formatted IO than low-level raw IO. However, when an implementation for the target protocol doesnโt exist, you may have no other choice.
Emilua offers stream.scanner(3em) for generic formatted textual input.
๐ฅ COMPOSED OPERATIONS ๐ฅ
As it may already be clear by now, many algorithms are compositions of raw IO operations. Unless the IO object synchronizes access on its own (and explicitly says so), you should be careful to not initiate extra IO operations that might affect the already in-flight operations for that object.
Concurrent writers operating on the same IO object is a common gotcha that causes corrupt streams during high-load scenarios (if "atomic" writes are not guaranteed by the underlying system). Suppose youโre generating line-delimited JSON objects on a UNIX stream socket. Youโre collecting info from various system services (e.g. "/run/acpid.socket"), and for each event, you generate a new JSON object.
@startuml
hide footbox
participant our_service
participant acpid
participant socket
acpid --> our_service : event 1001
our_service --> socket : json(event 1001)
[--> our_service : event 3001
our_service --> socket : json(event 3001)
@enduml
In other words, youโre multiplexing information from assorted sources. The same can happen on the web when youโre orchestrating microservices and dumping information on a WebSocket channel. Now, back to our example, if a short write happens, you might end up in the following state:
@startuml
hide footbox
participant writer1
participant writer2
participant write_all
participant stream
writer1 -> write_all : stream, buf1
activate write_all
write_all -> stream : write_some(buf1)
writer2 -> write_all : stream, buf2
activate write_all
write_all -> stream : write_some(buf2)
stream --> write_all : buf1/N1
write_all -> stream : write_some(buf1:sub(N1))
stream --> write_all : buf2/OK
write_all --> writer2 : buf2/OK
deactivate write_all
stream --> write_all : buf1:sub(N1)/OK
write_all --> writer1 : buf1/OK
deactivate write_all
@enduml
In other words, one of the messages didnโt fit in the kernel buffer, then stream.write_all() retried the operation to drain the buffer. However there was already another in-flight write operation, and it was scheduled first than buf1:sub(N1)). The end result will be a stream where the second message is inserted in the middle of another message (a corrupt stream):
+------------+----+------------+
| : : |
| head of M1 | M2 | tail of M1 |
| | | |
+------------+----+------------+
Note: This problem is not exclusive to async IO frameworks. The same behavior can be observed if you code for blocking APIs making use of threads to achieve concurrency.
To solve this problem, you should create a mutex to protect the write end of the stream:
scope(function()
stream_write_mtx:lock()
scope_cleanup_push(function() stream_write_mtx:unlock() end)
stream.write_all(stream, event_json)
end)
Other network frameworks for scripting languages try to solve the problem transparently by making use of an unbounded write buffer under the hood. However thatโs solving the issue in the wrong layer. If a write buffer is always used, the network framework can no longer appropriately communicate which user-issued write operation caused an error. The way such frameworks implement this solution is actually way worse as they face back-pressure issues as well, and have to band-aid patch the API all over.
Emilua will not inappropriately entangle all IO layers โ raw IO, buffered IO, formatted IO โ together. When you do want to make use of shared write buffers, you can write your own socket + the buffer (and mutex) to abstract this pattern in a way that wonโt cause problems to your application.
Do notice that such problems donโt exist when composed operations use operations that donโt overlap each other. For instance, you can use stream.read_all() and stream.write_all() on the same object with no synchronization because such use wonโt perform concurrent write_some() calls nor concurrent read_some() calls.
๐ WHY EOF IS AN ERROR ๐
Same rationale as Boost.Asio[2]:
- The end of a stream can cause
stream.read_all(3em),stream.read_at_least(3em), and other composed operations to violate their contract (e.g. a read of N bytes may finish early due to EOF). - An EOF error may be used to distinguish the end of a stream from a successful read of size 0.
๐ SEE ALSO ๐
https://techspot.zzzeek.org/2015/02/15/asynchronous-python-and-databases/https://sourceforge.net/p/asio/mailman/asio-users/thread/5357B16C.6070508%40mail1.stofanet.dk/
๐ NOTES ๐
[1]โ That applies to IO objects that expose system resources (e.g. TCP sockets). Higher-level abstractions built in user-space (e.g. TLS sockets) donโt apply.[2]โhttps://www.boost.org/doc/libs/1_81_0/doc/html/boost_asio/overview/core/streams.html
๐ Thatโs the page, bestie! Source/version: Emilua 0.12.1-nixpkgs1; date: 1980-01-01; manual: EMILUA(7). Bye! ๐
๐ฆ where this page comes from
- nixos 26.05: emilua 0.12.1
- nixpkgs unstable: emilua 0.12.1
- ๐ girlified by deepseek-ai/DeepSeek-V4.1-Flash-1017 on : a language model's rewrite of the original, under its terms