icey does a lot of work on one event loop with very little ceremony. That is a big part of why the library is fast. It also means there are a few rules you need to get right.
This page is about those rules.
It is not a tutorial and it is not the API reference. It is the compact version of "what assumptions the runtime is built on."
base, net, and http objects are loop-affine.Signal<T> is the fast local path. ThreadSignal<T> is the cross-thread path.send() is the borrowed fast path. sendOwned() is the retained path.PacketStream is zero-copy until it crosses an explicit retention boundary.close() is asynchronous. Treat shutdown as a state change, not as immediate destruction.If you keep those six rules in your head, most of icey makes sense.
Most runtime objects belong to one libuv loop thread.
That includes the types that actually do work:
basenethttpThe contract is simple:
Synchronizer, or some other explicit cross-thread handoffThis is deliberate. icey does not try to make every object transparently thread-safe. That would add locking and hide the real execution model.
If a type is meant to be shared across threads, the docs should say so explicitly. Otherwise assume loop affinity.
icey now has a clear split here.
Signal<T> is the default fast path. Use it when emission and subscription live on one thread, which is the normal case inside one libuv loop.ThreadSignal<T> is for real cross-thread emission or subscription.LocalSignal<T> is an alias for the same local fast-path semantics and is used where loop-local intent is part of the contract.That matters for both speed and design. If you reach for ThreadSignal<T> everywhere "just to be safe", you are paying for a wider contract than the code actually needs.
It also matters for reasoning about code. A loop-local signal says something useful about the object that owns it.
This is the most common mistake people make in async code.
When you receive a MutableBuffer in net or http, that buffer is borrowed for the duration of the callback. Nothing more.
That means:
If you need the bytes later:
That same rule applies higher in the stack when payload callbacks are just exposing transport buffers from below.
send() vs sendOwned()icey uses both on purpose.
send() is the hot path:
sendOwned() is the explicit retained path:
The rule is not subtle:
send()sendOwned()This is how icey stays fast without pretending buffer lifetime is magic.
PacketStream Stays Zero-Copy Until You Cross A BoundaryPacketStream is the data plane for most of the interesting parts of icey. It is also where ownership gets people into trouble if they stop paying attention.
The default rule is:
The first adapter reporting PacketRetention::Cloned or PacketRetention::Retained is the ownership boundary in that graph.
Common explicit boundaries are:
SyncPacketQueueAsyncPacketQueuesynchronizeOutput()Upstream code may only reuse or free borrowed storage after one of those boundaries, or after the whole synchronous call chain has returned.
If you are wiring media pipelines, this rule matters as much as the codec settings do.
close() in icey usually means:
It does not usually mean "the object is gone right now."
That affects how you write shutdown logic:
shared_ptr alive until the close path is doneThe base runtime now does a much better job of making that safe, but the model is still asynchronous by design.
Do not mutate live PacketStream graphs. Build the graph, then start it. Tear it down after it stops. Changing topology mid-flight is one of the fastest ways to make a clean pipeline confusing.
Use local signals when they are local. Use borrowed send when the payload naturally lives long enough. Use queues only when you actually need a thread hop or retention boundary.
start() and connection send() as synonymsThey are different on purpose.
start() starts the HTTP transactionsend() writes bytes on an already-established transaction or upgraded connectionIf you mix those up, you are fighting the model instead of using it.