A single-threaded C runtime for small native programs that talk to devices and networks. One loop, no threads, no compulsory dependencies, on Linux, macOS and Windows.
It is what a program sits on when it is not drawing and not deciding: the loop and the signals that stop it, a clock, a log, a Bluetooth LE central, the serial ports the system has, a TCP connection, a TLS session, an HTTPS request, an MQTT client, and the codecs that turn bytes from off a device into something a program can hold. None of it knows what a program is for.
application mesh-client, and whatever comes next
|
inkcell theme, fonts, layout, widgets, focus - a framebuffer or a window
| (optional: a headless program links inkwell alone)
inkwell loop, signals, clock, log, BLE, serial, net, TLS, codecs
|
the kernel Linux: epoll, timerfd, signalfd, BlueZ, sysfs
macOS: kqueue, CoreBluetooth, IOKit
Windows: waitable handles, Winsock events, WinRT Bluetooth, SetupAPI
inkcell is the UI toolkit that stands on it, but inkwell
does not need one above it: a headless program - a CLI, a daemon, a bridge - is an inkwell
program with nothing drawn. The arrows only point down. inkwell knows nothing about inkcell and
less about any application; scripts/check-layers.py is what holds that, because the linker will
not.
A program that holds a handful of peers - a radio on a serial line, a sensor over Bluetooth, a broker, an HTTPS endpoint - and has to answer all of them without ever stalling the one thing it draws or serves. That is a narrower job than a general event library's, and inkwell keeps it narrow on purpose: one of each. One loop per program, one hostname per resolver, one socket per connector, one session per TLS context, one request per fetch. Composition is the caller's. Something that wants a connection pool, a thread pool or a scheduler wants a different library, and the day this one grows them is the day it stops being small enough to read.
It was extracted from mesh-client, a Meshtastic client for the TrimUI Brick: everything in it that was never about Meshtastic and never about drawing.
| Area | What it owns |
|---|---|
base/ |
The floor: a monotonic clock and a wall clock you can distrust, a levelled log that bounds its own file, $PREFIX_-namespaced environment knobs, whole-file reads, UTF-8 that counts characters rather than bytes. Includes nothing, including from each other's area. |
runtime/ |
One loop with a bounded number of sources - epoll on Linux, kqueue on macOS, WaitForMultipleObjects() over waitable handles and Winsock events on Windows - no threads anywhere, a timer and a wake that are sources like any other, and SIGINT/SIGTERM/SIGHUP (a console control event, on Windows) delivered through the loop so a shutdown runs the ordinary path instead of the default kill action. |
codec/ |
Bytes in, bytes out: base64 in both alphabets, SHA-256 and MD5, a non-allocating JSON reader, an HTTP/1.1 request formatter and response parser that takes its input in whatever sized pieces the network hands it, a zip central-directory walker that works on a window of a file rather than the whole thing, inflate and PNG, the UF2 and ESP firmware image formats, and the serial protocol an ESP32's ROM bootloader speaks. A codec parses; it does not know what the bytes are for. |
net/ |
One hostname turned into an address without blocking the loop - a forked child on Linux, DNS-SD on macOS, overlapped GetAddrInfoExW on Windows; a non-blocking TCP connector with a deadline; a byte stream over a descriptor; TLS that reports -EAGAIN rather than waiting; one HTTPS request; and one bounded MQTT 3.1.1 client with subscriptions, keepalive, and reconnect backoff. All run on the same loop and hold no application policy. |
ble/ |
One Bluetooth LE central: discover, connect, pair, read, write, subscribe - BlueZ over D-Bus on Linux, CoreBluetooth on macOS, the Windows Runtime's LE API on Windows - as events on the loop. The only other threads in the tree are the stack's own - CoreBluetooth's dispatch queue, WinRT's thread pool - and both are kept to copies and a wake. |
io/ |
The USB serial ports the system has - sysfs on Linux, the I/O Registry on macOS, SetupAPI on Windows - with what the USB tree says about each (a bridge chip or the device's own USB, a mass-storage interface beside it or not), and a tty opened raw and non-blocking for the loop, whose rate and DTR/RTS lines can be changed while it is open. For a kernel without CDC-ACM, the generic-driver bind and the usbfs line-state request that make a native-USB device talk anyway. |
Everything here is C17, freestanding of any framework, and allocates as little as it can get away with. There are no threads and there will not be any: the loop is the concurrency model.
These are authoring rules — breaking one compiles and looks fine.
base/is a leaf. It includes nothing of inkwell's. A helper that reaches back up the tree is how a utility becomes a layer nobody can move, which is the exact mistake this repository exists to undo.- No threads. Everything is the one loop, and anything that would block gets something the loop can wait on instead — a timer, a signal source, a wake, a pipe from a forked child.
- Only
runtime/andbase/fd.cname the kernel. Everything above asks them for a timer, a wake, a pipe or a socket and registersINKWELL_LOOP_IN/_OUT. Atimerfd_create()above that line compiles on Linux and nowhere else, which is why CI builds on more than one system. - A blocking call is a bug.
getaddrinfo()has no non-blocking form, so on Linux the resolver forks - and on macOS asks mDNSResponder over a socket, because a forked child of a process with threads may not callgetaddrinfo()there; a TLS handshake reports-EAGAINall the way up rather than waiting. Nothing here may sit between two frames of somebody's UI. - A new directory under
src/needs an entry inALLOWEDinscripts/check-layers.pybefore it will compile clean. That is deliberate: adding an area is a decision about the shape of the stack, not amkdir. - Nothing in here names an application. A comment may say "a peer" or "a protocol"; the moment it says "the radio", the code has learned something it has no business knowing.
Three systems, each a target a program ships on, not a host it is merely worked on from. What
differs between them is confined to runtime/, base/fd.c and the one backend file an area picks
per system, so nothing above this layer names any of them.
| Linux | macOS | Windows | |
|---|---|---|---|
| Loop, timer, wake, signals | epoll, timerfd, eventfd, signalfd | kqueue | waitable handles, console control events |
| Crash report | yes | yes | yes |
| Codecs | yes | yes | yes |
| TCP connector, byte stream | yes | yes | yes, over Winsock |
| Resolver | forked child | DNS-SD over the loop | overlapped GetAddrInfoExW |
| TLS, HTTPS | Mbed TLS | Mbed TLS | Mbed TLS, over Winsock |
| MQTT client | yes, TLS through Mbed TLS | yes, TLS through Mbed TLS | yes, over Winsock; TLS through Mbed TLS |
| Bluetooth LE central | BlueZ over libdbus-1 | CoreBluetooth | Windows Runtime; no bonding |
| Serial ports | sysfs, plus the usbfs line-state request for a native-USB device | I/O Registry | SetupAPI, overlapped COM I/O |
| USB mass-storage writes | sysfs, /proc/mounts |
finds no drive | refuses |
| In CI | gcc, clang, ASan+UBSan, no optional deps | clang | gcc (UCRT64), the Windows suites |
"Refuses" is the same contract as a missing optional dependency: the header exists, every symbol
links, and a call reports itself unavailable, so a program builds unchanged on every system and
asks at runtime what it can do. Closing a gap is swapping the <name>_unavailable.c an area
compiles for a real backend and updating this table. A fourth system is a decision about the
shape of the stack, in the same way a new area is.
On macOS the Mbed TLS generator wants a Python with jinja2 and jsonschema; point CMake at one
with -DPython3_EXECUTABLE=... if the one on PATH does not have them.
make test # Debug build + ctest - the default verify step, and what CI runs
make debug # build only
make format # clang-format all tracked .c/.h (needs clang-format 18)make test runs the unit suite and scripts/check-layers.py. Run it before every push.
Sanitizers: cmake -S . -B build -DINKWELL_ENABLE_ASAN=ON -DINKWELL_ENABLE_UBSAN=ON. CI runs
the suite under gcc, under clang, and under both sanitizers.
Vendor it as a submodule and add_subdirectory() it; it builds as a consumer without owning the
parent's warning flags or building its own tests.
add_subdirectory(third_party/inkwell)
target_link_libraries(your_app PRIVATE inkwell::inkwell)TLS is optional. net/tls.h and the HTTPS and MQTT clients that stand on it need
Mbed TLS, which is a submodule at third_party/mbedtls. Clone with --recurse-submodules, or
git submodule update --init --recursive afterwards, and it is compiled in. Without it both
headers still exist and every symbol still links: a session refuses to start with -ENOTSUP and
a fetcher reports itself unavailable, so nothing above needs an #ifdef. Everything else builds
and tests identically either way, and CI has a job that proves it.
Two things are the application's to supply rather than inherit, both once at startup:
inkwell_tls_set_roots(my_roots, my_root_count); /* what this product trusts */
inkwell_fetch_set_user_agent("yourthing/1.4.2"); /* what it calls itself */There is no default root set and no fallback. With nothing registered a session is refused rather than opened unverified — which root store a product uses is a decision about how it ships, and a layer this far down guessing at it would be an insecure mode arrived at quietly.
One binary with a name filter, not per-test CTest entries. Cases live in tests/suites/<area>.c
and register themselves — write one with INKWELL_TEST_CASE(name, category) and it runs. A
new suite file goes in INKWELL_TEST_SUITES in tests/CMakeLists.txt.
./build/tests/inkwell_tests --list
./build/tests/inkwell_tests --filter loop
./build/tests/inkwell_tests --suite codec_zip- A word a user reads. A failure is a reason and a number (
net/reason.h); the application turns the pair into a sentence. Nothing here collapses two reasons it can tell apart, because merging them is a decision about words. - A policy dressed as a mechanism. A CA bundle, a
User-Agent, a retry schedule, a size limit: the mechanism lives here and the number is the caller's. A component that moved down left a two-line wrapper behind in the application holding exactly that number. - A string catalog. Translation stays in inkcell even though a headless program could use one, because "inkwell has no catalog" makes the no-words rule impossible to break rather than something to remember.
- Anything that names an application's domain, in code or in a comment.
- A thread. Whatever would block gets a descriptor and a callback.
MIT. See LICENSE.