Part 3: Buffers, Padding & Shift Buffers
If your change frames payloads, prepends a header, rewrites bytes in place, or
allocates working memory, you must understand sbuf_t, the buffer pools, and the
chain-wide left-padding contract. Getting this wrong produces tunnels that work in
one chain layout and corrupt memory in another.
Source of truth: ww/bufio/shiftbuffer.h, ww/bufio/buffer_pool.h, and the
required_padding_left field in ww/objects/node.h.
The sbuf_t Anatomy
WaterWall payloads are carried in sbuf_t ("shift buffer"), a padded, shiftable
byte buffer. Its header is exactly 32 bytes and the allocation's data area is
32-byte aligned:
struct sbuf_s
{
uint32_t curpos; // offset of the current payload start within buf[]
uint32_t len; // current payload length, in bytes
uint32_t capacity; // ordinary: storage capacity; splice: logical geometry
uint16_t l_pad; // reserved left padding (constant once created)
uint16_t flags;
uint8_t buf[]; // 32-byte aligned data
};
For ordinary buffers, the cursor moves inside a fixed allocation. The flag
kSbufFlagSplice gives the payload a different representation, described below.
Fresh and reset ordinary buffers start at padding rounded to 32 bytes. Moving
curpos can change the current payload's alignment; allocation alignment alone
does not justify aligned loads or stores through sbufGetRawPtr() or
sbufGetMutablePtr(). Check the actual address, including any copy offset.
The storage diagram and accessor descriptions here apply to ordinary buffers:
|<-- left capacity -->|<------- payload (len) ------->|<-- writeable tail -->|
+---------------------+-------------------------------+----------------------+
buf[0] curpos curpos+len capacity
|<------ l_pad ------>|
(reserved padding)
curposmoves left when you prepend (shift left) and right when you consume from the front.lenis how many payload bytes are valid starting atcurpos.- Left capacity (
sbufGetLeftCapacity=curpos) is how many bytes you can still prepend by shifting left. - Writable capacity (
sbufGetMaximumWriteableSize=capacity - curpos) is how much you can address forward from the cursor, including current payload. - Tail capacity (
sbufGetTailCapacity=capacity - curpos - len) is the spare room available to append bytes without growing an ordinary buffer. It requires valid ordinary geometry; it is not available for splice buffers.
Key Accessors
| Call | Returns |
|---|---|
sbufGetLength(b) | current payload length (len) |
sbufGetRawPtr(b) / sbufGetMutablePtr(b) | pointer to the payload start (buf + curpos) |
sbufGetLeftCapacity(b) | bytes available to prepend (curpos) |
sbufGetMaximumWriteableSize(b) | bytes addressable from the cursor (capacity - curpos) |
sbufGetTailCapacity(b) | spare append space in an ordinary buffer (capacity - curpos - len) |
sbufGetTotalCapacity(b) | full allocation (constant) |
sbufGetLeftPadding(b) | the original aligned l_pad value |
Reading and Writing
Aligned and unaligned integer helpers exist for header fields:
sbufWriteUI16(b, port); // aligned 16-bit write at the cursor
sbufWriteUnAlignedUI32(b, value); // unaligned 32-bit write
uint16_t v; sbufReadUI16(b, &v); // aligned read
sbufWrite(b, src, n); // raw copy of n bytes at the cursor
sbufConsume(b, n); // drop n bytes from the payload tail count
sbufShiftRight(b, n); // advance cursor: consume n bytes from the front
sbufSlice(source, bytes) consumes leading ordinary bytes into a new ordinary
buffer with the source's full original aligned left padding. The caller owns
both the result and the remaining source, including when the source becomes empty.
Packet checksum repair requests
kSbufFlagRecalculateChecksum uses the existing sbuf_t.flags storage without
changing the 32-byte header. sbufSetRecalculateChecksum(buf, requested) changes
only this bit; sbufGetRecalculateChecksum(buf) reads it. Requesting repair
requires an ordinary resident buffer and asserts against a splice wrapper.
True means this packet has an outstanding request for the existing checksum repair operation. False means no repair requested; it does not certify checksum validity. Deliberately invalid checksums remain possible. The bit is independent of TUN-private checksum assurance, hardware offload and GSO metadata.
Fresh allocations, reset and ordinary pool reuse clear the request. Whole-payload
sbufDuplicateTo(), sbufDuplicate() and sbufDuplicateByPool() copy it exactly,
including clearing a previously marked destination when the source is unmarked.
A failed duplicate leaves destination bytes, geometry and flags unchanged.
Each clone owns its own request; consuming one does not change another.
sbufReserveSpace() preserves the request across replacement or no growth; use
only its returned pointer after it may replace the allocation.
Byte-copy helpers, including sbufWriteBuf(), do not transfer intent. New partial
moves, slices and range extractions start unmarked and do not consume the source's
request. Concatenation preserves the destination bit through growth without ORing
source bits. Cursor/length changes preserve the bit mechanically; mutable access
and byte writers do not infer checksum consequences. Packet-aware callers must
complete repair before losing a packet boundary, or explicitly assign intent to
complete constructed packets. Segmentation, fragmentation and synthetic output
choose intent for each result according to their checksum generation semantics.
Splice wrappers stay unmarked; materialization never creates packet intent.
Forwarding transfers bytes and intent together. After forwarding or recycling, the sender must not inspect or change either. A successful complete repair clears the bit; an incremental correction covering only a transform's own edit preserves an earlier request. A terminal consumer may take it before repair because refusal discards the packet. A transform that declines and forwards unchanged preserves both bytes and request. See packet boundaries in Part 4.
Splice payloads: kSbufFlagSplice
A splice allocation keeps the common 32-byte sbuf_t header and exactly
32 bytes of control storage plus reserved left padding. Metadata at buf + l_pad
contains its int pipefd[2] and actual kernel capacity. A complete sbuf_t * is
itself the master-pool entry: there is no separate pipe record or entry pointer.
kSbufFlagSplicePooled identifies fixed inventory entries and remains set while
cached or awaiting release after retirement. No source socket or WIO is retained.
sbufSplicePoolInitialize(capacity_limit, pipe_capacity, max_pipe_count, padding)
runs during exclusive startup, after every configuration's chains have finalized
padding and before any prepare/start hook or producer runs. It freezes the aligned
padding and configures empty master storage and the capacity/count ceilings. It
returns whether configuration succeeded and allocates no buffers or pipes.
Checkout reuses existing entries first, then creates only the missing requested
batch under a shared growth mutex. Only nonblocking, close-on-exec pipes meeting
the required capacity and remaining aggregate budget enter the inventory; account
their actual kernel-reported capacity. A pipe creation or sizing refusal stops
that refill, retains earlier successes and delays further creation attempts for
one second across all workers. Ordinary-buffer fallback remains available.
Heap allocation follows the
fatal policy.
Production requests 1 MiB pipes. The process-wide capacity ceiling follows the RAM profile:
| Profile | Capacity ceiling |
|---|---|
S1 (minimal / ultralow, numeric 0/1) | 32 MiB |
| S2 (numeric 2) | 64 MiB |
M1 (client, numeric 3) | 128 MiB |
M2 (client-larger, numeric 4) | 256 MiB |
| L1 (numeric 5) | 384 MiB |
L2 (server, numeric 6) and higher internal profiles | 512 MiB |
When a successful initial memory snapshot is available, cap this ceiling at one fifth
of effective_total_bytes: host RAM bounded by every applicable visible finite
cgroup limit. Use total capacity, never free/available RAM, and round down to
whole 1 MiB pipes. The resulting ceiling may be below 32 MiB or zero. If memory
detection is unsupported or unavailable, retain the profile ceiling and emit a
diagnostic. Freeze the budget immediately after global-state construction; its
initial total remains usable even if startup has outlasted the sampler's freshness
window. Do not recalculate it from runtime pressure samples. Empty pipe
capacity does not reserve all corresponding payload memory.
The shared inventory owns one master_pool_t with room for every permitted entry,
so cache overflow cannot close a healthy pipe.
Each worker buffer pool caches at most eight complete empty splice buffers,
independently of its ordinary cache width. A local miss takes up to four existing
entries from the master or bounded lazy growth; a partial batch is valid.
masterpoolTryGetItems() itself never allocates missing entries. On local cache overflow, four entries return to the
master before retaining the new return. These limits count unused cached buffers,
not active buffers held by a worker. Local plus master plus active entries never
exceed the admitted inventory. Healthy entries remain allocated until final
teardown. Lifetime admitted bytes/count enforce the growth limits separately
from current usable bytes/count, so retiring a failed pipe does not permit a
replacement.
If both the requesting worker's cache and the master are empty and bounded growth
cannot supply an entry, checkout returns NULL with ENOBUFS, even when another
worker retains unused cached entries.
Unsupported builds use ENOSYS. The caller chooses whether to defer reading or
use ordinary storage; existing I/O paths use ordinary fallback where possible.
There is no cross-worker cache stealing or waiting for a return.
Every pooled buffer retains its original aligned l_pad. Inventory padding is
shared through GSTATE.splice_inventory. Allocation, recycling, direct destruction
and padding updates reject a mismatch with a fatal diagnostic in every build,
before interpreting metadata using an altered offset. curpos may still move
within advertised headroom. sbufCreateSplice(pad_left) only allocates an unmanaged
empty representation; it cannot acquire or create an inventory pipe. Its separate
geometry is useful for explicit representation fixtures, not inventory growth.
kSbufFlagSplice identifies the splice representation and stays set while cached.
Use the inline sbufIsSplice(buf) helper to query it on a valid buffer; it does
not inspect payload bytes or require a pipe syscall.
Any claimed body bytes must already be in the wrapper's private pipe. There is
no separate body-location flag. An empty body may have an uninitialized pipe
or an existing empty pair; empty-body helpers need no pipe creation.
The real prefix occupies reserved headroom:
prefix_bytes = l_pad - curpos
body_bytes = len - prefix_bytes
Length and capacity describe logical payload, not resident bytes. Prefix consumption advances the cursor; body consumption reduces length and logical capacity without advancing into metadata. Prepending within advertised padding is permitted. Inspecting or overwriting the kernel-backed body or control storage through generic byte accessors is not permitted; those accessors do not add splice checks. Opaque synchronous forwarding and size accounting remain valid.
sbufGetResidentPrefixLength() returns the addressable prefix of a splice
buffer, or the full length of an ordinary buffer. After successful pipe I/O,
sbufSpliceConsumeBody(buf, bytes) updates length and logical capacity together
without moving the cursor. This accounting helper does not read or discard pipe
bytes itself; use it only for bytes already removed from the private pipe.
sbufEnsureOrdinary(pool, buf) returns ordinary input unchanged. For exclusively
owned splice input it materializes the complete resident prefix and private-pipe
body into best-fit ordinary storage with the pool's large-buffer padding, then
recycles the source wrapper. The caller owns only the returned buffer. No callbacks
occur, and allocation follows bufferpoolGetBestFit()'s fail-fast contract. The
checked counterpart, sbufTryEnsureOrdinary(pool, buf), returns NULL when the
destination geometry cannot be represented, leaving the source unchanged and
caller-owned. Allocation otherwise retains the pool's fail-fast behavior.
HalfDuplexServer uses this checked helper so refusal still closes its association
without consuming the rejected input. The node chooses when ordinary storage is
needed; custom framing, range extraction, and pre-conversion retained-charge
checks keep their specialized conversion paths.
Mode selection and pipe-backed reads
The core misc.splice boolean defaults to true. Setting it to false keeps
chain->supports_splice off for every chain. Finalization evaluates this startup
policy after topology expansion, alongside platform support, absence of packet
nodes, and splice support from every node. Enabling the option does not override
those eligibility checks or change node capability flags.
When splice is enabled on a supported Linux build, core startup first tries to
raise fs.pipe-user-pages-soft to the smaller of the calculated capacity ceiling converted
to system pages and a finite fs.pipe-user-pages-hard. An unlimited hard limit
(0) does not reduce the target. Page conversion rounds up if needed. These
live system-wide settings govern each user's aggregate pipe-page allowance;
they are not per-process resource limits. An unlimited soft limit (0), or a
soft value already at or above the target, causes no change. Read/parse/write
and page-size-query failures are nonfatal. The hard limit and fs.pipe-max-size
are never changed, and no persistent sysctl configuration is written. A
successful increase remains in effect after WaterWall exits.
Core constructs global state and tunes pipe limits. sbufSplicePoolPrepare()
publishes the small shared control object before imports can copy global state,
without allocating buffers or pipes. Its heap allocation follows the fatal policy;
unsupported builds have no inventory. Core then builds all configurations
with nodemanagerBuildConfigFile(). Chain finalization accumulates the maximum
padding across configurations. The core configures the empty inventory with that
padding, then nodemanagerStartConfigs() admits packet bootstrap, prepares all
configurations, and starts producers. GSTATE.splice_inventory owns the master,
capacity/count ceilings, live count/capacity, growth synchronization, immutable
padding and optional discard descriptor. State copies
share this object even when taken before final padding is known. Final inventory
configuration fills that same object; it never replaces it. Padding is unset during
exclusive configuration and frozen before any checkout. State copies do
not copy counters or create another inventory.
In addition to the capacity budget,
the pipe count is capped at one eighth of the remaining descriptor allowance:
subtract current open descriptors from the process's soft RLIMIT_NOFILE, then
divide by eight. Its two descriptors per pipe therefore use at most one quarter
of the allowance still available at startup. The Linux descriptor scan includes
its own temporary descriptor conservatively; an unavailable limit or scan leaves
the inventory empty. Kernel resource limits may reduce successful acquisition
further. Startup reports the configured capacity ceiling and zero allocation;
sbufSplicePoolCapacityLimit() exposes the immutable byte ceiling.
sbufSplicePoolCount() and sbufSplicePoolCapacity() expose current usable
inventory, including pipes leased to buffers. Retiring a failed pipe reduces
these values; no replacement is created. Disabled splice skips both the sysctl
operation and inventory allocation.
wioEnableSplice() and wioDisableSplice() select the read mode on the WIO's
owning worker. The WIO must be open. Selection may change before or after reads
start, including from a read callback; no read stop/restart is needed. The next
read observes the selected mode. Already delivered buffers, retained private
pipes, and queued writes keep their representations and ownership. Writes
dispatch by the buffer's representation, independently of the read mode.
Enabling on supported builds returns zero without creating a pipe; unsupported
builds return -1 with ENOSYS. New descriptor adoption resets the mode.
A normal line has two one-way ordinary-read preferences, initially clear:
linePreferOrdinaryReadUpstream(l)requests ordinary source reads that produce upstream payload: TcpListener applies it to its accepted socket.linePreferOrdinaryReadDownstream(l)requests ordinary source reads that produce downstream payload: TcpConnector applies it to its connected socket.linePreferOrdinaryReadBoth(l)requests both directions on the same line by calling the two directional helpers.
Call these helpers on the live line's owning worker. Requests may occur during
Init or later, are idempotent, and remain set for that exact line's lifetime.
There is no clear operation: one node must not undo another node's preference.
The linePrefersOrdinaryReadUpstream() and linePrefersOrdinaryReadDownstream()
queries expose the flags. A clear flag permits the adapter's normal splice policy;
it never overrides chain eligibility or the core setting.
TcpListener initially selects splice only for an eligible finalized chain, then applies its preference after Init and before reading. TcpConnector consults its preference when adopting the socket and again before connected reads start. Both adapters also apply preferences at receive and payload/write callback entry, including queued payload, write completion and read Resume. Applying a preference only disables splice; it never re-enables it. TcpListener owns its normal line; TcpConnector borrows it. These two sockets can share the same line and consult different flags. No WIO pointer or ownership is stored in the line header.
These preferences are advisory, not representation guarantees. The receive callback runs after its input was read: that delivery can still be splice, and a preference requested while processing it may only be noticed on the following receive. Nodes must still handle both representations and must not use the flag as permission to inspect private pipe bytes as ordinary memory. Preferences do not propagate to associated lines: a Mux child cannot disable its shared parent's socket, and Mux need not materialize child payload because of a preference. Choosing ordinary reads for a short parsing phase still applies for the rest of the line; there is no automatic restoration after a handshake.
For NIO TCP splice reads, the read loop checks out a splice wrapper with its
private pipe already initialized by the pool and calls splice() directly. Each
request uses bufferpoolGetSplicePayloadLimit(), independently of ordinary
allocation sizes: 1 MiB in every memory profile.
There is no socket FIONREAD query before a
successful splice delivery. The socket and pipe are nonblocking, and the transfer
happens before invoking the Payload callback.
Production pipe capacity and the read limit are both 1 MiB. A full-sized pipe does not promise a full-sized transfer: pipe slot layout and available input can still produce a shorter result. Initialized pipes continue consuming their per-user capacity allowance and two descriptors while idle in the inventory.
Only the actual positive splice result is published as the body length. Logical
capacity becomes left padding plus that count; kSbufFlagSplice stays set.
A positive short splice produces a smaller valid buffer; remaining socket bytes
are handled by later reads. The requested count is an upper bound, not a promise
of a full-size transfer.
EINTR retries the transfer. On EAGAIN or a zero result, the empty wrapper is
recycled and the loop attempts one ordinary nonblocking receive through the
large-buffer path. TCP urgent data can stop splice at the urgent boundary while
recv() can advance and deliver subsequent ordinary bytes. With a peer FIN
already received, splice can even return zero at that boundary before those bytes
are drained. The ordinary receive confirms EOF rather than treating a zero splice
result alone as proof that the stream has no remaining data.
A successful fallback delivers ordinary bytes with the actual received length.
Ordinary EAGAIN/EINTR returns to the event loop; ordinary zero closes the stream.
Other splice errors recycle the wrapper and follow normal read-error closure.
If the inventory cannot reuse or create a pipe within its limits, checkout
returns NULL with ENOBUFS; the read loop also falls back to an
ordinary receive. None of these fallbacks has consumed socket bytes in the splice attempt.
Splice mode stays enabled for later reads.
Ordinary TCP receives, including these fallbacks, take the pool's large buffer
directly without a socket FIONREAD query. Linux UDP retains its size hint.
Disabled, non-TCP, and unsupported splice paths retain ordinary behavior.
The common NIO event handler checks admission before entering the read path; the read path checks again before delivery. If shutdown suppresses delivery after a successful splice, WIO drains and recycles the still-local wrapper. A missing read callback also releases the undelivered buffer. After invoking the callback, the read path accesses neither the buffer nor the source WIO. The callback may close/free the source immediately.
There is no FD-backed wrapper state, socket reservation, or post-callback source reference check. WIO owns its primary descriptor directly; emitted buffers have no dependency on that descriptor's lifetime.
Independent private bodies
Socket -> this buffer's private pipe -> Payload callback
Buffer A -> private pipe A: [AAA]
Buffer B -> private pipe B: [BBB]
Every emitted splice body is independent as soon as the callback receives it. Splice-aware code may retain the whole buffer or a partially consumed remainder for later use, including after source WIO reclamation. No preparation call or source reference is needed before deferral. Consume the private body through the dedicated pipe helpers and preserve exclusive buffer ownership.
Converting B first reads BBB and leaves AAA intact, including when A is queued. Final stream delivery must still preserve A-before-B order. Pool access remains worker-affine; an explicitly transferred wrapper may use its receiving worker's pool. BufferQueue preserves these owned entries. The fixed-header splice stream accepts ordinary and splice entries together; BufferStream is ordinary-only. Callers must not scan private-pipe metadata as payload.
Conversion and partial reads
sbufSpliceMaterializeToBuffer(buf, dest, pool) replaces an ordinary
destination payload with the real prefix and body, preserving the source cursor
and total length. It checks destination capacity through sbuf accessors; the
destination's allocation geometry and left padding remain unchanged.
It recycles the consumed wrapper, which callers
must not access afterward.
sbufSpliceReadToBuffer(buf, dest, bytes) appends exactly the requested
prefix/body bytes. It validates source length, cursor, and destination append
space, without growing storage or consuming destination headroom. Zero bytes
is a validated no-op. The caller retains both buffers and returns the wrapper
through bufferpoolReuseBuffer() when finished, discarding any unconsumed remainder.
Router and SniffRouter establish an ordinary-memory boundary while their route state is undecided: each complete splice delivery is materialized before pending storage or classification, preserving onward pool padding. Their pending buffers remain ordinary and are replayed intact when the selected branch is initialized. After route commitment, both callback directions forward opaque buffers unchanged. Metadata-only Router rules can commit during Init and preserve the first splice delivery. Complete-delivery materialization preserves the existing sniff-window semantics, including classification of complete headers larger than the incomplete-input cutoff.
TLS protocol/SNI sniffing reassembles a fragmented ClientHello in bounded caller
scratch without modifying the retained wire bytes. The SNI result borrows either
the original single-record payload or that scratch; both must remain alive until
the caller consumes the view. Router and SniffRouter use this sniffing path.
tlsclienthelloParseRecord() retains its single-record offsets and semantics for
callers that inspect or rewrite encoded records.
StreamFragmenter's optional ClientHello inspection consumes only the bounded initial wire prefix through these range helpers, including private-pipe and resident-prefix-plus-pipe input. Its ordinary accumulator retains the original record headers for byte-identical replay on unsupported framing or deadline expiry. The replacement is reserved alongside the accumulator before record headers are rewritten; an already-admitted opaque suffix remains in the normal FIFO budget. Later opaque splice wrappers retain their direct forwarding path.
Its candidate allocation requests start at 4 KiB and grow to at most 393,216 bytes; rewriting adds at most 320 bytes. A pool may supply a larger buffer, whose actual capacity still counts against the budget. Generic fragments come from jobs already admitted under the 8 MiB logical-byte limit. With the pool's validated, aligned padding, these requested sizes have representable allocation geometry on supported targets. Keep budget and timer refusals, but do not add recoverable heap or geometry failures to these bounded allocations. At rewrite commitment, allocating the computed rewrite length guarantees the helper's output capacity; assert that result while keeping the rewrite call outside the assertion and the helper's checked capacity API intact.
Both read helpers require kSbufFlagSplice and read only
from the wrapper's private pipe. A nonempty body requires an initialized pair.
Positive short reads accumulate until the required byte count is reached, and
EINTR retries without losing progress. Invalid flags/bounds, EOF, or any other
read error before completion use LOGF() and abortProgramNow(1). This includes
EAGAIN: the private pipe must already hold all claimed bytes, so an incomplete
body cannot be deferred until more bytes arrive. These exact-size helper reads
are distinct from the read loop's socket-to-pipe transfer, whose actual positive
result determines delivery length.
Unsupported conversion and partial reads abort.
Recycling, discard, and physical destruction
bufferpoolReuseBuffer(pool, buf) takes exclusive ownership and discards remaining
payload. Healthy inventory entries retain both descriptors and return to the local
cache or shared master; pool bypass returns directly to the master. Unmanaged
wrappers are freed, and failed inventory entries are retired without replacement.
WIO cleanup, BufferQueue destruction, lineReuseBuffer() and reuseBuffer() share
this behavior. Ordinary cache overflow never controls splice inventory lifetime.
A wrapper claiming zero length must already have an empty pipe. The pool uses
sbufSpliceIsReusable(buf) to check kernel emptiness with FIONREAD only when
BUFFER_POOL_DEBUG == 1, including when pooling is bypassed. Failure uses
LOGF() and abortProgramNow(1) before the pipe becomes available to another
buffer. This check is independent of NDEBUG. With pool debugging disabled,
recycling trusts the consumption helpers' byte accounting. Resetting sbuf
fields never empties a pipe.
sbufSpliceDiscard() requires exclusive ownership and leaves an empty wrapper
without freeing it or returning a healthy lease. For nonempty payloads it drops
the real prefix and drains the private pipe with nonblocking splice() to
/dev/null, retrying EINTR and accepting positive short transfers until
EAGAIN confirms emptiness. Startup opens one optional discard descriptor after
configuring the empty inventory; it stays open and unchanged through all worker and foreign-thread
buffer cleanup and closes at final inventory destruction. If opening the sink
fails or a splice fails with an error other than EINTR or EAGAIN, discard
falls back to nonblocking reads with bounded scratch storage for that call.
The read fallback also retries EINTR and drains until EAGAIN. EOF or a failed
read closes both owned ends and retires the inventory entry permanently.
A later checkout may borrow another remaining pipe but must never recreate
the retired one.
sbufDestroySplice() drains and returns a healthy complete inventory buffer to the
global master without accessing a worker-local pool. It physically frees retired
entries and unmanaged wrappers. This is also sbufDestroy()'s splice path, so
foreign-thread cancellation safely returns scarce resources. There is a separate
physical destructor for final inventory destruction; it cannot recursively recycle
entries into the master being destroyed.
Local caches keep their splice flags and pipe metadata. Their entries count as
checked out from the master until returned to it. Worker/device pool destruction
flushes them into the global master. After workers, callbacks, active buffers and
local caches have settled, sbufSplicePoolDestroy() requires zero outstanding
entries, closes remaining pipes, destroys the master and discard descriptor, and
frees and clears the owning global-state pointer. Imported state copies borrow the
same inventory and must finish before owner destruction. Outstanding entries are
a fatal lifecycle invariant violation.
Writing splice payloads
Supported NIO TCP writes accept splice buffers whose body bytes are already in
their private pipes. Empty and prefix-only wrappers need no initialized pipe.
Writing or queueing never reads the source socket. Real prefixes use send before bodies
use nonblocking splice to the destination. When supported, TCP prefix sends use
MSG_MORE when that same buffer has a nonempty private-pipe body, including
partial-prefix retries. Ordinary and prefix-only sends also use MSG_MORE when
nonempty bytes follow in the same socket's WIO queue; body splices then use
SPLICE_F_MORE alongside SPLICE_F_NONBLOCK. Each queued attempt recomputes
whether bytes remain beyond the current buffer, so the last send/body clears
the hint. The hint permits TCP to coalesce adjacent writes. They remain separate
system calls; the hint does not guarantee packet boundaries or immediate
transmission if a following write stalls. UDP has its separate datagram contract.
wioWriteWithHint() lets a caller advertise nonempty bytes following in its
current drain to the same TCP socket. TcpListener and TcpConnector derive this
from their pause queue's remaining byte count after popping the current entry,
both on resumed drains and Finish flushes. Empty entries do not count. This hint
applies only to the immediate write attempt; it is never stored on a queued
buffer. After a partial write or yield, WIO retries use only their own actual
queue, which may split a batch but cannot retain a stale caller hint. Ordinary
wioWrite() supplies no caller hint. Unsupported backends ignore it.
Actual combined progress drives
logical queue accounting. Short sends, EAGAIN, and EINTR retain the remainder
in the existing WIO FIFO, shared with ordinary writes. Queued buffers require no
source handle and remain valid after source reclamation.
Completion recycles empty wrappers. Rejection, output error, overflow, timeout,
forced close, and teardown discard private bodies through the same cleanup path.
Graceful TCP close drains the queue normally. Supported NIO UDP destinations
instead use the one-datagram path below. Generic wioWrite() rejects splice
input fatally on unsupported builds or destinations other than TCP/UDP;
wioWriteDatagram() consumes raw-IP splice input and returns -1 with EINVAL.
The shared udpSendBuffer() returns ENOSYS for splice input on unsupported
builds, which never enable splice chains. Callback admission and reentrancy
follow the normal WIO contracts.
UDP datagram sending
udpSendBuffer() in the event layer retains caller ownership of the buffer on
all results, consumes accepted splice prefixes and actual pipe bytes, and returns
whole-datagram success, captured error and a separate socket-retirement verdict.
NIO and UdpStatelessSocket share it. UDP receives remain ordinary; raw-IP writes
never gain splice support. The caller runs the complete transaction and any
retirement on the socket's owning worker; the helper does not transfer ownership.
The caller supplies that socket owner's buffer pool. Any materialization fallback
uses best-fit storage with zero required left padding and returns the temporary
buffer to the same pool on success or failure. The input remains caller-owned,
including when it was posted from another worker.
All sends on that socket, including ordinary sends, remain serialized across the
transaction. No callback or other send may run while assembly is pending.
One sbuf is one datagram. Prefix-only and empty splice inputs use one ordinary
send. For nonempty bodies, send the real prefix (possibly empty) to the immutable
explicit destination with MSG_MORE, transfer the entire body with
SPLICE_F_NONBLOCK | SPLICE_F_MORE, then issue an empty send without MSG_MORE
to commit. These empty operations do not publish extra datagrams. Do not change
a shared socket's connection or default peer. IPv4 and mapped destinations allow
at most 65,507 payload bytes; IPv6 allows 65,527, including resident prefixes.
Route/MTU errors still belong to the kernel. There is no splitting or UDP retry
queue; ordinary sends retain their one-shot policy (UdpStatelessSocket retains
its ordinary/priming EINTR retry).
Older Linux UDP sendpage implementations have a finite skb fragment limit, so a small logical body can still be unrepresentable when spread over many pipe slots. A pipe-capacity bound of at most 64 KiB limits its slot count conservatively; resident prefix pages reduce that budget, rounded down to a power-of-two number of pipe slots. For larger or unknown-capacity pipes, select full materialization and one ordinary send before touching the socket. Runtime UDP sends never allocate or resize a probe pipe. This conservative layout fallback preserves datagram atomicity without exceeding the fixed inventory. Pipes whose capacity proves the fragment bound may splice their bodies without userspace reads. Kernel-internal copies are still possible, and no measured performance improvement is promised. The fragment bound follows Linux's skb fragment limit and UDP sendpage assembly; newer kernels use splice-to-socket.
Before priming, normal errors/drop policy settles the untouched input. After
successful priming, any short/failed splice or failed final commit retires the
socket through its owner. A positive short count may conceal an error that
already discarded the pending datagram, so never loop over it or send its suffix.
Account actual pipe consumption before recycling. Never clear UDP_CORK or
connect to AF_UNSPEC as an abort trick. The shared helper closes no FD itself;
its caller must prevent further writes and retire through WIO after settling the
buffer, before returning. Write/close callbacks may destroy associated lines.
See Part 7 for socket-owned
peer cleanup.
BufferQueue splice ownership
BufferQueue accepts ordinary and splice buffers through its existing push, pop, peek, reserve, and destroy API. It preserves entries, including empty entries, and FIFO order. Logical byte accounting includes each splice buffer's real prefix and remaining private-pipe body; it does not count control-storage capacity.
bufferqueueGetBufLen() reports logical bytes. bufferqueueGetCharge() reports
the sum of sbufGetQueueCharge() for entries still owned by the queue. Both are
cached O(1) totals, maintained by successful front/back insertion and pop. Empty
entries contribute charge even though they add no logical bytes. Callers choose
limits, their accounting scope, Pause/Resume decisions and overflow handling.
An optional attached buffer_budget_t enforces admission across one or more
queues and active reservations. Unbound queues retain their existing accounting
without a finite admission policy; parser and Mux policies remain with their owners.
Splice insertion retains the original wrapper and private pipe in every build. It neither copies metadata as payload nor converts or consumes the body. Splice entries must already own any claimed body bytes in their private pipes; empty and prefix-only wrappers need no initialized pipe. Ordinary Debug insertion continues to replace allocations to detect stale aliases.
Successful insertion transfers ownership to the queue. Failed try-insertion leaves the pointer, buffer, private-pipe contents, queue entries, and byte/charge totals unchanged. Unrepresentable totals are refused before ownership transfers, including before ordinary Debug replacement. Heap exhaustion while reserving deque storage is fatal; it does not produce a try-insertion refusal. Pop transfers ownership back to the caller. Peek is borrowed: do not consume or modify a queued buffer through an alias. Pop first, consume through the splice helpers, and reinsert any remainder so queue accounting stays correct.
Use bufferqueuePushBack() or bufferqueuePushFront() when the caller already
proves that bytes, queue charge, entry storage and any attached budget admit the
buffer. A payload-length limit alone does not prove this: retained capacity can
be much larger. Keep the try form wherever those checks can still refuse.
Destroying a nonempty queue discards remaining private bodies and recycles the wrappers on the owning event worker, preserving healthy empty pipes. Drain errors close the affected pair before recycling; teardown never creates a replacement. Destroy releases only this queue's contribution to its attached budget and leaves a valid empty, unbound queue with both totals zero. Empty queue destruction requires no worker context. TCP adapter pause queues use these same ownership rules; final stream delivery order remains the caller's responsibility.
A budget tracks logical bytes, canonical queue-capacity charge and entries in O(1).
Initialize all three limits explicitly: SIZE_MAX is unrestricted and zero is a
real zero allowance. Admission checks representability even in unrestricted
dimensions. Empty buffers consume charge and an entry. Budgets own no buffers,
pools, callbacks or flow-control state; each lives at a stable address on its
owning worker until all attached queues and active reservations have settled.
Limits remain fixed while in use.
bufferqueueTryAttachBudget() requires an unbound queue and atomically admits its
existing totals before publishing the binding. It can attach populated parser
storage when that queue becomes output storage. Refusal preserves the queue and
budget unchanged. Initialize the final line-state object before attaching queues
to embedded budgets; copying a temporary with self-references is invalid.
bufferqueueTryRebindBudget() transfers a bound queue's current bytes, charge
and entries to another live worker-owned budget. Destination refusal changes
neither budget nor the queue; rebinding to the same budget succeeds without
re-admission. Success changes no buffers, FIFO positions or representations and
performs no allocation or callbacks. Previously popped reservations keep their
original budget. Empty queues change their binding too: even empty destruction
accesses the bound budget, so its owner must remain alive until rebinding or
queue destruction.
Ordinary bufferqueuePopFront() releases the entry's budget cost before returning
ownership. When the same scope continues retaining a popped buffer, use
bufferqueuePopFrontReserved() with an empty reservation. It moves accounting to
the active owner without reducing aggregate usage or attempting new admission.
Reduce reservation bytes as plaintext or WIO progress is consumed, before reentrant
callbacks can admit more input. Retained capacity and the entry remain charged
until responsibility ends. Release clears the reservation; empty release is safe.
Never copy one live reservation into two independently released owners.
Same-budget bufferqueueTryPushFrontReserved() and bufferqueueTryPushBackReserved()
reserve destination storage before transferring accounting, without charging twice.
Their reservation must match the actual buffer cost; reconcile any consumption
first. Failure preserves the buffer and reservation; success clears the reservation.
bufferqueueReserveExtra() reserves deque slots only, never budget allowance.
Whole-queue moves carry the binding and both cached totals by assignment, then initialize the relinquished source empty and unbound. Destroy an empty destination's storage before overwriting it. Never reset a populated queue to erase accounting. Destroy all queues and release all active reservations before asserting an embedded budget empty and clearing its owner. Recreated queues must attach explicitly again.
BufferStream accepts ordinary entries only. The fixed-header splice stream owns mixed representations for Mux; NIO's internal WIO FIFO stores owned wrappers directly.
Exact extraction from an active head and queue
bufferRangeTakeExact(pool, &active_head, &tail, bytes) in
ww/bufio/buffer_queue_range.h consumes an exact FIFO range from a separately
owned mutable head followed by an unbound BufferQueue. The caller must establish
complete availability and representable ordinary output geometry, including full
pool large-buffer padding, before calling. The active head must be exclusively
owned and absent from every queue. The queue must remain unbound: this operation
does not preserve the active reservations required by a budgeted parser.
The returned buffer is exclusively caller-owned and has the pool's full
large-buffer left padding available. A suitable exact whole head transfers
directly; otherwise the helper assembles the requested range. Representation
selection follows the contributing source wrappers: a splice wrapper selects
splice assembly even when the requested bytes lie only in its resident prefix.
Pipe checkout or transfer pressure can instead produce complete ordinary output.
The helper retains the authoritative replacement destination from every
sbufMoveRangeTo() call, including fallback after partial progress.
Queued buffers are inspected without mutation and popped before consumption. Exhausted heads are recycled through the supplied pool; any suffix remains in the active head before the untouched queued tail. A zero-byte request returns one empty ordinary buffer without consuming or changing either input storage location. The operation runs on the pool's owning worker, invokes no callbacks, and owns no persistent cursor or cleanup object.
Protocol metadata, retained-byte and admission policy, authentication, readiness, reentry guards and lifecycle remain with the caller. Commit protocol progress before delivering the extracted buffer through a callback, and preserve the existing line-reference and association checks around that delivery. Node cleanup continues to release the active head and queue.
Prepending a Header: sbufShiftLeft
The whole reason l_pad exists is so a tunnel can prepend a protocol header
without reallocating or memmoving the payload. You move the cursor left and
write into the freed space:
// Reserve room and move the cursor back by header_len bytes.
assert(sbufGetLeftCapacity(buf) >= header_len);
sbufShiftLeft(buf, header_len); // curpos -= header_len; len += header_len
// Now write the header at the new front.
sbufWriteUI16(buf, htons(payload_len));
// ... fill the rest of the header ...
sbufShiftLeft asserts that enough left capacity exists:
static inline void sbufShiftLeft(sbuf_t *const b, const uint32_t bytes)
{
assert(sbufGetLeftCapacity(b) >= bytes);
b->curpos -= bytes;
b->len += bytes;
}
If the assertion fires (or, in a release build, if you shift left past the start), you have prepended more than your padding budget allows — which is the next section.
The Padding Contract: required_padding_left
A tunnel may only rely on left padding it advertised in its node.c:
.required_padding_left = kMyProtocolHeaderSize,
During chain finalization, the runtime sums required_padding_left across every
node in the chain and reserves that much left padding in the buffers it hands out
(tunnelchainInsert accumulates sum_padding_left; tunnelchainFinalize calls
globalstateUpdateAllocationPadding). In other words:
The left capacity available to your tunnel at runtime is the sum of the padding budgets of your tunnel and every tunnel closer to the adapter that produced the buffer. If you prepend bytes you never advertised, you are spending someone else's budget, and the buffer may not have it in a different chain.
This is why a framing tunnel that "works on my machine" can corrupt memory once a user puts a different node in front of it. Advertise what you prepend.
Rules:
- Use
sbufShiftLeft()only whensbufGetLeftCapacity()is at least the header size you need. - Keep every prepend within this tunnel's advertised
required_padding_left. - If you must prepend more than your budget, either raise
required_padding_leftto match, or build a fresh buffer (see below) instead of shifting.
Buffer Pools
For ordinary runtime buffers, do not malloc. Use the worker-local buffer pool so
allocations are recycled and correctly padded. The pool is reachable from any
line:
buffer_pool_t *pool = lineGetBufferPool(line); // this line's worker pool
sbuf_t *big = bufferpoolGetLargeBuffer(pool); // ordinary read/working buffer
sbuf_t *medium = bufferpoolGetMediumBuffer(pool); // 32 KiB in S1/S2, 64 KiB in higher profiles
sbuf_t *small = bufferpoolGetSmallBuffer(pool); // small working buffer
sbuf_t *splice_buf = bufferpoolGetSpliceBuffer(pool); // leased private pipe, or NULL when exhausted
// ... use the buffer ...
bufferpoolReuseBuffer(pool, buf); // return it to the pool
lineReuseBuffer(line, buf); // convenience: pool of line's worker
Pools are per worker. A line belongs to exactly one worker (lineGetWID), so
always use that line's pool — never a different worker's. Buffers from a pool
already carry the chain's reserved left padding, so a pooled buffer is the correct
place to build a payload you intend to prepend onto.
Pool metadata and ordinary buffer allocation follow the
fatal heap-allocation policy.
bufferpoolCreate() remains nullable for invalid construction inputs or
unrepresentable geometry. Bounded splice-inventory exhaustion is separate from heap
exhaustion and keeps its documented ordinary-buffer fallback.
Each buffer pool has large, medium and small ordinary caches backed by three
shared ordinary masters. bufferpoolCreate() takes those masters in that order,
then the ordinary cache width, large/medium/small payload capacities, the splice
payload limit and the waiting-budget basis. The latter two values must be in
[1, INT_MAX] and stay fixed after construction. Device pools inherit the sizes
and limits from their worker pool. Splice caches use the shared bounded inventory
without a separate wrapper master or a constructor argument for one.
Device backends use devicePoolCreate() with an explicit, exclusively accessible
worker pool and a backend-specific minimum small tier. Construction preserves
zero initial padding; devicePoolUpdatePadding() copies all four finalized
padding values during BringUp, before publishing I/O producers. Neither helper
obtains a worker pool from thread-local identity. Private TUN reader pools use the packet
MTU as their minimum; Linux worker readers use their owner worker pools and a
shared fragment table with detached allocations. Darwin receives its family
prefix into separate storage.
The worker GSO output allowance and its exact-sized fallback remain a separate
calculation.
Worker and device pools select ordinary cache width with
PROPER_BUFFER_POOL_WIDTH(). Ordinary masters use twice that width as their
constructor argument. Splice caches have a fixed eight-entry limit; their global
master is sized for the maximum permitted inventory; the configured profile and
detected RAM ceiling affect its global budget, not local cache width.
The master's mutex transfers ownership and visibility of cached item slots.
Its atomic len uses relaxed loads/stores: reads outside the mutex are advisory
empty/full probes, and slot access always holds the mutex. checked_out has a
different role: terminal returns can finish outside that mutex, so their release
decrements pair with acquire reads before teardown frees pool-family resources.
Observing zero does not close checkout admission; the owner must prevent new
checkouts and satisfy the existing lifecycle requirements before destruction.
Master constructors double their argument, giving these actual entry capacities:
| Profile | Ordinary local width | Each ordinary master |
|---|---|---|
S1 / minimal | 1 | 4 |
| S2 | 4 | 16 |
M1 / client | 8 | 32 |
M2 / client-larger | 32 | 128 |
| L1 | 64 | 256 |
L2 / server | 128 | 512 |
Ordinary local pointer arrays hold twice their selected width. Recycling shrinks
an ordinary tier once its cached count exceeds floor(4 * width / 3), transferring
up to one width of entries to its master; a full ordinary master destroys overflow.
These are idle cache bounds, not limits on active allocations.
Payload capacities, excluding reserved left padding, are:
| Tier | Capacity | Use |
|---|---|---|
| Small | 4 KiB | Small ordinary payloads and control messages |
| Medium | 32 KiB in S1/S2; 64 KiB in M1/M2/L1/L2 | Framing, retained queues including paused Mux children, and best-fit UDP reads |
| Large | 64 KiB in S1/S2; 128 KiB in M1/M2/L1/L2 | Ordinary event-loop read buffers and larger working payloads |
| Splice storage | 32 bytes of control storage | Explicit splice wrappers; logical body size is independent of allocation size |
| Splice payload limit | 1 MiB in every profile | Socket read limit and required new-pipe capacity |
S1 and S2 share buffer sizes but retain different profile-based local and master cache capacities; S1 caches fewer allocations. Higher profiles retain 64 KiB medium and 128 KiB large buffers. Their independent splice target remains 1 MiB. NIO ordinary TCP reads use the large tier directly. Linux NIO UDP reads select an ordinary tier through best-fit allocation when a positive size hint is available. Medium buffers also serve nodes and retained queues. Mux selects the smallest fitting ordinary tier; a full 1 MiB frame requires dedicated ordinary storage in every profile. The wire payload maximum remains 1 MiB.
bufferpoolGetBestFit() selects the smallest ordinary tier that satisfies both
payload and padding requirements, comparing actual sizes across small, medium,
and large. If no tier fits, it creates a dedicated padded buffer that is destroyed
on return. bufferpoolTryGetBestFit() accepts a 64-bit computed length and returns
NULL for unrepresentable geometry before using the same allocation path. Neither
helper shrinks an existing buffer. Splice allocation stays explicit.
A successful best-fit allocation already satisfies the requested payload capacity
and left padding; callers need no undersized-success recovery branch. Use
bufferpoolGetBestFit() when caller-established bounds guarantee representable
geometry, including padding and alignment; otherwise preserve
bufferpoolTryGetBestFit()'s geometry refusal. These guarantees describe the
returned buffer at allocation time. Incoming or subsequently shifted buffers
still need the applicable capacity and prepend checks.
sbufCreateWithPadding() makes a separate ordinary sbuf_t and follows the same
fatal heap-allocation policy as pool refill. Validate computed geometry with
sbufTryComputeCapacity() before creating storage when an unrepresentable request
must be refused locally. Destroy this direct buffer with
sbufDestroy() after its last user has stopped.
The 128 KiB tier is not a maximum payload or total-memory limit. Smaller pooled allocations do not cap process memory, kernel pipe memory, frame sizes, or dedicated ordinary allocations. Frequent large materializations can incur allocation/free work; these sizes make no throughput guarantee.
Materialization callers must request the complete logical payload plus required headroom, including real prefixes, residual padding and any destination bytes already present. Low-level conversion helpers do not grow caller storage. For example, 600 KiB requires dedicated ordinary storage with a 128 KiB large tier; a pipe with 1 MiB capacity holding only 20 KiB needs storage for its logical bytes, not its kernel capacity. Dedicated buffers above all tiers are destroyed on recycling, while consumed splice wrappers return to their separate splice cache.
Each tier refills at most four buffers at a time, further bounded by half the local cache capacity and its available slots. Pools whose previous refill batch was smaller than four retain that smaller batch. Cache capacities and shrink thresholds are independent of this refill limit.
Fresh sbuf_t allocations with more than 2 KiB of physical buffer storage,
including reserved left padding and alignment rounding but excluding the header,
are prefaulted in shiftbuffer.c. Release builds write volatile zero bytes at
4096-byte strides and at the final byte, covering trailing unaligned pages.
Debug builds retain their existing full-storage diagnostic fill, which already
touches every page. The supported minimum page size is assumed to be 4096 bytes;
no OS page-size query is performed.
This applies to newly allocated pool buffers, dedicated buffers and replacement allocations during growth. Pool checkout and recycling do not repeat the writes. Splice wrappers use only their physical padding and control-storage size, never their logical pipe payload size. Length, padding and ownership rules are unchanged; callers must still initialize payload bytes before use. Prefaulting pays possible first-write page faults during allocation, may populate unused capacity, and does not pin memory or guarantee a throughput improvement.
bufferpoolGetSpliceBufferStorageSize() and bufferpoolGetSpliceBufferPadding() expose
the splice geometry. bufferpoolUpdateAllocationPaddings() accepts separate
large, medium, small, and splice padding arguments. Global chain-padding updates cover
all four tiers, and device pools inherit each tier's padding value from their
worker pool. Finalize padding before creating the splice inventory or populating
local caches; splice padding cannot change after inventory initialization.
The existing lineGetBufferPool(), getWorkerBufferPool(), and
getCurrentWorkerBufferPool() accessors expose every tier through the same pool, including
bufferpoolGetMediumBuffer() and bufferpoolGetSpliceBuffer(). lineReuseBuffer() and reuseBuffer() use the same
recycling path as bufferpoolReuseBuffer(), subject to their existing worker
ownership requirements. Ordinary recycling clears flags and length. Splice
recycling preserves its representation/ownership flags and pipe, clears payload,
and restores control-storage capacity and the original padding cursor.
bufferpoolGetSpliceBuffer(pool) returns a complete buffer from the local cache,
shared master or bounded lazy growth. A refusal returns NULL with ENOBUFS; unsupported
builds use ENOSYS. Handle this before accessing the buffer, normally by choosing
ordinary storage. Bypass uses the same bounded global inventory without local caching.
Only checkout/refill may grow the inventory; returning a healthy buffer never
creates, resizes or closes its pipe.
If you need a buffer bigger than the current one for an append, use
sbufReserveSpace() (it reallocates and copies only when necessary) rather than
hand-rolling growth.
sbufCreateSplice(pad_left) directly allocates an empty buffer with exactly
32 bytes of payload capacity, plus left padding rounded up to a 32-byte
boundary. Its payload capacity stays at 32 bytes regardless of the CPU cache
line size. The buffer starts with zero length and only kSbufFlagSplice set;
sbufDestroy() physically frees this unmanaged allocation. Inventory entries
are acquired through the pool APIs and preserve their flags while cached.
Ordinary TCP and UDP receive sizing
NIO ordinary TCP reads take the pool's large buffer directly and call recv()
with its full writable payload capacity, without first querying FIONREAD.
This also applies to an ordinary receive after a failed pipe allocation or a
splice attempt that makes no progress. A receive may return fewer bytes than
the capacity; remaining stream bytes stay queued for later reads. Its actual
return value determines the published length, and zero confirms TCP EOF.
Buffer capacity does not define TCP application message boundaries.
On Linux, UDP uses FIONREAD as an allocation hint for the full size of the next
datagram, not the sum of queued packets. bufferpoolGetBestFit() requests that
complete size. It can select a larger tier or dedicated storage if the datagram
exceeds a custom pool's large tier. Each receive still delivers one datagram.
The socket has one serialized reader between the query and receive.
UDP best-fit reads request at least the large tier's reserved left padding, so a smaller tier with insufficient padding is ineligible. A failed or zero size query falls back to a large-buffer receive. Zero never skips the receive: it may represent an empty UDP datagram or transient readiness. The actual receive result determines the published length, and Linux UDP truncation detection still drops incomplete datagrams.
TunDevice uses its separate raw-IP/GSO reader and buffer sizing. Other NIO socket types and non-Linux/IOCP receive paths retain their own allocation policy.
Sizing Destination Buffers for Untrusted or Variable Payloads
When sizing a destination buffer from an untrusted or variable payload length:
- Use
bufferpoolGetBestFit()(or another checked growth operation) rather than assuming a fixed buffer tier fits the input. Supply the left padding required by the onward buffer contract as well as the payload capacity: when no pool tier fits, best-fit creates a dedicated buffer with exactly the requested padding. Request zero only for a locally consumed buffer or a proven zero-padding destination. - Compare the requested length with actual writable capacity
(
sbufGetMaximumWriteableSize()) in ordinary Release control flow before any assert-only length mutator (such assbufSetLength()) or write. - Publish the
sbuf_tlength only after the producer has successfully written or validated that many bytes.
Buffer Ownership
Buffer lifetime bugs are as common as line lifetime bugs. The rules:
- A callback takes ownership of the buffer you pass it. After
tunnelNextUpStreamPayload(t, line, buf)(or the downstream form),bufis no longer yours. Do not read it, reuse it, or free it. - If you keep a buffer, you own it. You are responsible for eventually
recycling it with
bufferpoolReuseBuffer/lineReuseBuffer, or handing it to a callback. - On an error path that still owns a buffer, return it to the pool (or the line) before you close. Do not leak it, and do not touch it after the line dies.
- If a re-entrant callback killed the line (a
falsefromlineCallWithRef()/lineCallWithRefWithBuf()), only recycle buffers you still hold; do not touch the dead line's state to do it.
ContextQueue owns enqueued contexts until pop or destruction. contextqueuePop()
returns NULL when empty; otherwise ownership transfers to the caller.
contextqueuePush() terminates on deque insertion failure.
After transferring or recycling a context's payload, contextDropPayload()
clears its payload pointer in every build configuration. It does not free the
buffer. contextReusePayload() and bufferStreamPushContextPayload() perform
this bookkeeping so later context destruction cannot recycle the payload again.
BufferStream representation, inspection, and extraction contracts
buffer_stream_t accepts ordinary resident buffers only. Use its views,
CRLF searches, exact/at-least/full reads, and IdealRead() only on ordinary
input. Logical stream entries are storage boundaries, not protocol records.
Push owns its input and preserves bounded ordinary coalescing: a positive input
of at most 4 KiB may append to a tail with sufficient spare space. Exact reads
preserve checked lengths and the explicit use_left_padding budget. Deque
insertion failure terminates during push or exact-read remainder retention.
Both stream implementations operate exclusively on bytes and owned buffers.
Fragment assembly belongs to the device reader session; no claim, callback, sidecar token,
or hidden padding metadata follows a packet through buffer transforms.
bufferstreamMoveExactBytesTo() validates the source range and destination append
space before consumption. It appends into sufficient caller-owned ordinary
storage without growing/replacing it or spending additional headroom.
Views and searches consume no bytes. The delimiter searches inspect all buffered
ordinary bytes, including delimiters split across entries. bufferstreamFindCRLF()
returns the CR offset; bufferstreamFindDoubleCRLF() returns the offset after the
delimiter. No match leaves the output offset unchanged.
HTTP parsers remain ordinary-only. HttpClient and HttpServer do not advertise
splice support. HttpProxyServer establishes an ordinary-memory boundary at
payload admission: only a complete, immediately forwardable splice wrapper in
ready CONNECT/fallback relay bypasses materialization. Every input, output,
deferred and active incoming session slot is ordinary. Parsing, transitions,
Pause and reentry materialize the complete delivery through the shared splice
read helper, preserving onward padding. The temporary ordinary input is at most
P + D logical bytes and meets the existing remainder allocation bound based on
max(small_capacity, 2 * D), plus padding, alignment and sbuf header. Existing
admission limits what survives return; conversion adds no persistent allowance
and makes no RSS or copy-cost guarantee.
Fixed-header splice stream
splice_stream_t in ww/bufio/splice_stream.{h,c} owns a FIFO, an explicit active
head, an immutable header size and a trailing resident header cache. It borrows
its owner-worker pool. Construction checks object
allocation arithmetic. Mux parents use an eight-byte header cache; Mux children
have no receive stream. Bgp4Client and Bgp4Server use a 19-byte cache for marker,
length and type. BGP OPEN inspection consumes only ten fixed bytes and up to 255
optional bytes through representation-aware range helpers. Application bodies
remain eligible for private-pipe extraction and per-frame delivery. BGP uses a
fixed 2,162,705-byte logical budget per direction and a 1,024-frame output cap;
incoming fragment count is unrestricted. Both are ordinary middle nodes on a
borrowed normal line: default Pause/Resume callbacks forward every notification,
including repeats, and complete admitted frames drain without waiting for Resume.
Per-direction pump guards preserve FIFO across reentrant callbacks. The receive
stream retains incomplete input. Each encoded batch is prepared before its first
handoff, and the output FIFO retains queued frames during reentry. Every input
still undergoes its applicable retention checks before callbacks, including
reentrant input. Ongoing framing prevents permanent bypass.
splicestreamDiscardFrame() consumes the cached header and exactly the requested
body bytes without producing a payload buffer. Its complete-header/body preconditions
match frame extraction. Resident bytes are skipped; Linux private-pipe bodies drain
to a lazily opened, close-on-exec /dev/null descriptor owned by the stream. Short
transfers and EINTR preserve progress. Unavailable sink/unsupported splice falls back
to bounded 4 KiB scratch reads; missing claimed pipe contents remain fatal. The next
header refills only after the selected body is consumed. Destruction closes the sink
and releases every retained input through the owner-worker pool.
SpeedTestClient and SpeedTestServer use a 48-byte splice header cache and advertise
splice support. Client verify-payload defaults to false and HELLO flag bit 8 selects
the same policy at both endpoints. Unverified DATA consumes its body through frame
discard; verified DATA and control bodies materialize only after enough bytes arrive.
Partial frames return to the event loop. Each line bounds incomplete-frame charge to
2 * (16 MiB + 48), attempting beneficial compaction before refusal. Headers, lengths,
stream IDs, sequence statistics and completion reports are always processed. Unverified
senders initialize ordinary bodies to zero; verified senders generate the deterministic
pattern. JSON summaries expose verification_enabled, and unverified payloads do not
increment valid_packets. UDP keeps its per-datagram path.
Their private frame encoders receive DATA sizes already bounded to 16 MiB by client settings or the accepted HELLO; control bodies are fixed and at most 72 bytes. The 48-byte prefix and validated pool padding therefore have representable allocation geometry. Assert these caller bounds instead of adding nullable frame allocation or duplicate UDP-size checks. The shared header readers still validate external input, and report/HELLO dispatch establishes their exact body sizes before private readers run. Sender pacing preserves wide byte counts when converting them to elapsed microseconds and caps unrepresentable delays before narrowing.
splicestreamPush() takes ownership on every result. It checks accounting and
reserves FIFO metadata before consuming any new header bytes; representability
refusal recycles the submitted input while preserving existing bytes and costs.
Heap exhaustion is fatal. Empty inputs recycle immediately.
Ordinary bytes, real resident splice prefixes and private pipe bodies maintain
FIFO order. Push physically removes only missing header bytes from sources and
recycles exhausted sources. The header cache owns only bytes.
splicestreamPeekHeader() returns a borrowed cache pointer only when complete;
the pointer expires on mutation. Repeated peeks perform no I/O or allocation.
splicestreamLength() includes cached header bytes; splicestreamBodyBytes()
returns zero until the header is complete, then counts all following bytes.
Neither peeking nor waiting for a body refills a second header.
splicestreamMoveFrame(stream, destination, N) consumes the current cached header
and exactly N body bytes. Header readiness and complete body availability are
caller preconditions, together with an empty destination; Debug asserts these
without repeating the decoder's availability checks in Release. The supplied
destination transfers ownership; always use the
returned pointer because fallback may recycle/replace the supplied allocation.
A NULL destination selects ordinary output, including after failed pipe checkout.
The result preserves full onward pool padding. Resident-only ranges remain
ordinary, including real prefixes of splice wrappers. Representation selection
examines only the requested body bytes, in the active head and queued buffers;
later pipe bytes do not make an earlier resident-only body use a pipe.
splicestreamMoveFrameToOrdinary() has the same caller preconditions
and retains fatal checks for ordinary destination type and sufficient storage
before consuming; it never grows or replaces the destination.
After the body completes, the stream clears its cache and gathers only the next
header. N=0 consumes one zero-body frame. With header size zero, extraction is an
exact body move with no header discard or look-ahead.
splicestreamMoveFrameChunk(stream, destination, N, finish_frame) moves exactly
N available body bytes using the same ownership, representation and padding
rules. The caller tracks the declared remaining body length. With finish_frame
false, the complete header stays cached and no next header is gathered; only the
final body chunk may set it true. splicestreamDiscardFrameChunk() follows the
same header-completion rule without producing a buffer. A zero-byte final discard
consumes an empty frame without allocating a payload. Stream length continues to
include the cached header until completion; body bytes already consumed do not
remain in its logical length. Source capacity and charge follow the ordinary and
splice consumption rules below. Mux uses these APIs for incremental delivery and
discard; complete-frame users retain their existing extraction APIs.
Pipe-backed extraction can split one source pipe or combine several independently
owned sources into a new private pipe. Whole suitable wrappers transfer directly.
Ordinary ranges and real prefixes copy/write in order; pipe bodies use nonblocking
pipe-to-pipe splice(). Positive short progress accumulates and EINTR retries.
Only successful transfers advance lengths; body consumption never moves the cursor
into metadata at buf + l_pad. No descriptors have two owning wrappers.
Pipe destination capacity is fallible: nominal F_GETPIPE_SZ does not prove slot
availability. Creation/growth failure, unsupported destinations and destination
pressure select complete ordinary fallback, not process termination. Allocate the
final best-fit result first, drain only the actual assembled destination bytes,
then append the still-unmoved source range. Return exactly N ordered bytes once,
leaving unrequested suffixes intact. Missing claimed source bytes or invalid owned
descriptors remain fatal invariants. Exact transfer primitives live in
splice_buffer, independently of Mux and the event loop.
SoftIpLimiter identity and SpeedLimit shaping
SoftIpLimiter materializes each complete delivery through sbufEnsureOrdinary()
only while identity admission is undecided. Empty deliveries contribute no identity
bytes and are recycled before buffering. Its BufferStream stays ordinary and
replays the original identity bytes and coalesced tail intact. The committed phase
is published before next Init; nested input retains its private pipe in the
existing bounded FIFO behind initial replay. Established and passthrough payloads
in both directions are opaque. Temporary inspection never sets permanent
ordinary-read preferences.
Each admitted line records its IP row's generation. Pruning and recreating the same identity/IP pair creates a new generation; an old line must neither refresh nor release the replacement row. Concurrent lines admitted to the same live row share its generation and reference count. Generation allocation and table changes stay under the existing instance write lock.
SpeedLimit retains ordinary and splice entries in its existing directional FIFOs.
Whole grants forward the original wrapper; partial grants pop the source, extract
exactly the permitted range through sbufMoveRangeTo(), and reinsert its remainder
before any callback. Splice splits use an independent destination pipe with complete
ordinary fallback on pipe creation or transfer pressure. Ordinary ranges preserve
onward pool padding. Each FIFO has explicit logical-byte, queue-capacity-charge and
entry budgets, including empty entries. Failed admission recycles the rejected
buffer and settles both queues before closing the borrowed normal line through
its owner. Queue pop/extraction/reinsertion invokes no callbacks; remaining ownership
and accounting are published before a slice is transferred. Reinserting a split
remainder reuses the popped slot and no greater byte/charge cost, with no callback
in between; use the ordinary push wrapper. Initial admission still
retains its ordinary refusal path. Finish deletes both
timers and destroys both queues before asserting their budgets empty.
UserController retained payload and delivery
UserController's stream and datagram FIFOs retain original ordinary or splice buffers. Stream grants use logical
sbufGetLength() and exact sbufMoveRangeTo() splits, preserving independent private pipes and ordinary fallback.
Datagrams are never split; zero-length entries retain their FIFO position and transfer once without rate or quota cost.
Reject datagrams above 65,535 bytes without charging, while preserving any smaller producer bound.
Both directions share line ceilings of 1 MiB logical bytes, 2 MiB actual allocation charge and 256 buffers. One user's queues in one AuthenticationClient share 4 MiB, 8 MiB, 1024 buffers and 1024 direction tickets; that client shares 64 MiB, 128 MiB, 16384 buffers and 16384 tickets. Each line has at most two direction tickets. These are resource bounds, not bandwidth settings. Queues and tickets allocate lazily. Owner-local budget primitives remain owner-local; synchronized user/client reservations account shared costs and roll back admission refusal without lock inversion.
Detached/prepared output remains part of retained costs until handoff or discard. Reserve split destinations and required continuations before the shared bandwidth/quota commit. A preview grants no ownership of credit; a concurrent worker or refresh may invalidate it. No fallible work intervenes between a successful commit and the neighboring Payload callback. Stream retention refusal discards the refused buffer and closes that line; datagram overflow drops only the new whole entry and preserves accepted FIFO contents. Required timer/continuation refusal closes either mode. Every still-owned buffer returns to its line pool, and transferred buffers are never recycled locally.
Reservation identity includes its runtime ticket. Removal and recreation of the same numeric user ID must not let late release subtract from the replacement account. Client aggregate cost stays reserved while old buffers remain retained, and the owner releases it once. See Part 7 for cancellation and shutdown ordering.
HeaderServer and KeepAlive framing
HeaderServer materializes each complete delivery with sbufEnsureOrdinary()
only while waiting for its port, TCP/UDP protocol, or PROXY protocol header. BufferStream remains
ordinary, including coalesced application tails. It publishes the established
phase before next Init; nested input then enters the existing opaque FIFO behind
the original tail. Constant-port mode and established payloads in both directions
preserve splice buffers. Temporary parsing does not set permanent ordinary-read
preferences.
HeaderClient protocol modes snapshot exactly the selected source or destination
protocol during Init, before next Init can rewrite the context. A one-byte header
carries 6 for TCP or 17 for UDP; unset, ambiguous, or unsupported protocol
flags close the borrowed line through its owner without next Init. The snapshot
is zeroed before Finish or before bypassing and handing off the first prefixed
payload. Port and PROXY modes keep no per-line state and use default Init/Finish.
The active-node mask prevents another header. HeaderServer's protocol mode
consumes the byte and changes only destination protocol before next Init;
addresses, ports, and source metadata remain intact. This marker does not provide
UDP datagram framing.
In constant-port mode, HeaderServer publishes the destination context, destroys
and zeros its line state, and bypasses itself before next Init. Parsed-header
modes wait until next Init and the ordered initial replay finish. This keeps
nested input behind the original tail before retirement.
Both nodes relay repeated Pause/Resume once the destination side is initialized;
neither retains consumer permission to gate output.
KeepAliveClient and KeepAliveServer frame every payload throughout the connection.
The prefix contains a four-byte big-endian body length and one kind byte. Each
frame carries at most 1 MiB of payload; length includes the kind, so body length
is at most 1 MiB + 1. Oversized lengths reject immediately after the header.
Their five-byte splice-stream cache consumes only frame length and kind; normal
bodies can remain in private pipes. Encoders prepend only in advertised real
headroom, preserving complete wrappers when one frame suffices. Larger payloads
split through sbufMoveRangeTo(), with complete ordinary fallback on pipe pressure.
No generic resident-buffer reserve/copy operation touches a splice body.
Each encoder publishes its active suffix before callbacks and serializes nested
inputs behind it. Active suffix plus queued nested input share an 8 MiB logical
reentry limit and at most 1,024 buffers, including the active suffix. Decoders serialize nested input
under an 8 MiB logical reentry bound. Complete frames drain before checking the
16 MiB retained allocation charge; beneficial compaction precedes charge refusal.
Validated frame lengths and draining guarantee that the incomplete remainder is
smaller than 1 MiB + 5 bytes, including the cached header. Check this derived bound
with a Debug assertion. Nested admission also checks the charge bound.
Body lengths outside 1..1,048,577 or refusal close through the normal
line owner instead of silently losing TCP bytes. Both nodes require L4 on both
edges and borrow normal lines; a mixed chain does not send its worker packet line
through them. Finish settles the stream, active suffix and reentry queue. Pause
relays promptly while an admitted synchronous batch can complete. In both client
modes, the first ping is due one ping-interval after downstream transport Est;
waiting for it does not block application data or start a reply deadline. Repeated
Est does not reset this schedule. Timer checks and outstanding monotonic reply
deadlines continue during either directional Pause. Both nodes relay repeated
Pause/Resume through the default callbacks. Due pings and pong replies do not
wait for Resume. An unsent ping blocked by an active encoder or an outstanding
pong leaves one due obligation without starting another reply deadline.
Each parsed Ping sends its Pong directly; the decoder's existing reentry guard
keeps nested input behind older frames under the same input bounds. No separate
pending-Pong queue is needed. Finish stops the drain and discards retained input.
The optional reply watchdog uses ordinary empty control frames and leaves body
representation unchanged. Missing or late empty pongs close through the borrowed
normal line's owner; the framing node does not recreate the line. KeepAliveServer
remains the responder.
Both nodes remain active throughout the line because every payload still needs
framing and control processing; neither is eligible for permanent bypass.
Protocol storage and admitted batches
APIServer's hard input-byte limit includes older complete/active request charges, raw backlog and the current
partial frame. Its ordinary byte watermarks subtract only the current frame's copied header/body. Predictive
pressure compares retained older request bytes plus the next complete frame against that same hard limit:
use max_request_envelope + 4 before its header is known, then its declared complete size. Count already buffered
bytes once. Request completion and output drain must release the hold and Resume without new input. An incomplete
frame alone must remain able to receive its missing bytes. Preserve hysteresis, independent memory/fragment limits,
absolute frame deadlines and bounded closure when late or coalesced input exceeds hard caps; advisory Pause is no
reservation. Protect line lifetime across Pause/Resume, which may synchronously Finish the borrowed line.
AuthenticationServer is an exact-line response endpoint. Its queued and active backing responses share one stable budget: 1024 entries, 16 MiB plus the 20-byte envelope in logical bytes, and 32 MiB allocation charge. Reserved pop preserves active charge; a guarded drain publishes its cursor before callbacks and emits at most 16 KiB per stream chunk with full chain padding. Refusal closes that borrowed line toward its owner after settling all retained state. One bounded frame, growing message, serialized text and output chunk are construction scratch outside that budget; see the node reference for the precise envelope and overlap limits. Parser reentry storage is also bounded at each authentication endpoint. AuthenticationClient retains the ordinary 32 MiB charge, 1024-entry and maximum-wire-message limits, with 2 MiB nested headroom. AuthenticationServer grants those limits only with validated token evidence. Unvalidated server input is bounded to 16 KiB of frame payload plus the 68-byte envelope, 64 retained fragments, and allocation charge no greater than the larger of 128 KiB and one best-fit pool allocation for that envelope. Inspect the length/token prefix before waiting for a body, and revalidate at dispatch. Nested bytes and the older unread suffix share their applicable allowance; a valid earlier message does not authorize a large unvalidated successor. Compact oversized carriers and tiny-fragment runs before returning from admission. Extraction copies fragmented messages once. Server response capacity grows geometrically within the complete-response cap; old and new allocations can overlap briefly during replacement. The current synchronous delivery, one extracted message and temporary compaction overlap are outside the retained-input allowance. Empty input retains nothing. Publish the older suffix before callbacks so nested input cannot overtake it; Finish settles the remaining stream.
Choose storage by the work it represents: incomplete metadata/body, a protocol wait, an initialization/reentrancy ordering barrier, delayed replay, or an association's independent producers. An otherwise-ready synchronous TCP buffer must not acquire another output queue solely for Pause or Est. See Part 2's admission contract.
Ordinary middle-node timers and deferred drains may submit due output through advisory Pause while preserving FIFO and protocol readiness. Retained data still belongs to that node's hard budget: a receiving adapter's limit cannot protect storage that has not reached it. Pause requests and eventual Resume do not reserve capacity, and overload may close the affected connection. Node-specific gates described below are local implementation behavior, not a generic scheduling rule.
Middle tunnels that need an application-data backlog use a default budget of 2 MiB logical bytes and 1,024 buffers per retention budget. Count queued buffers and active backlog buffers still retained under that budget; multiple queues sharing it do not each receive a separate allowance. This is a hard admission limit, not a universal payload-size limit, a memory/RSS limit or a Pause threshold. Do not create a queue on an otherwise-ready synchronous forwarding path just to apply the default.
Protocol parsers, frame allowances, ciphertext shaping and transport/Mux storage retain their own explicit bounds. Explicit node-specific application-backlog limits take precedence over the default. For example, Socks5Server retains its 1 MiB / 1,024-buffer ordering limit per direction, including active retained tails and nested input together. Each negotiation message contains at most 513 metadata bytes; complete messages are consumed before waiting for more input. Fragmented metadata extraction copies only the protocol prefix, excluding application bytes after a complete CONNECT header. Empty TCP deliveries consume no staging entries; UDP-control tails are discarded after association acceptance. Socks5Client follows the default application-data limit for its upstream negotiation FIFO and, independently, downstream input retained for parser reentry or an older response backlog. The latter counts active stream bytes and queued sources together. Its 4,096-byte limit bounds input waiting for the first transport Est or incomplete reply metadata; prefix extraction does not merge an arbitrary coalesced body. Ready direct payloads are not subject to a backlog cap, and empty TCP input does not consume retention entries.
Socks5Client and Socks5Server materialize complete negotiation deliveries with
sbufEnsureOrdinary() before parser or reentrant-input admission. BufferStream
stays ordinary; complete coalesced application tails are replayed without charging
them as incomplete metadata. Client upstream application storage can retain
splice buffers opaquely. After TCP setup, direct payload and necessary FIFO
forwarding preserve original buffers. Server input conversion continues through
CONNECT-wait until its TCP-established phase; success-reply ordering is unchanged.
Their ready direct TCP ordering queues drain through advisory Pause. The client
still waits for SOCKS command success before releasing application data, and the
server still places its one success reply before response bodies. UDP association
producer control remains separate from these same-line TCP drains.
ConnectionFisherClient and ConnectionFisherServer materialize complete deliveries only before ordinary handshake/selection stream admission. Their five-byte markers are parsed as soon as complete; an incomplete marker retains at most four bytes. The following application body has its own inclusive 2 MiB allowance, independent of delivery fragmentation or coalescing. Client downstream selection ordering has that same byte bound; upstream waiting queues and server temporary input share 2 MiB and 1,024-buffer bounds with their retained input, including the server body held across next Init. Empty staged TCP input consumes no entries.
Their application FIFOs can retain exclusive private pipes. After protocol setup and the necessary FIFO drain, they release ordinary handshake streams and empty queue storage. Ready payloads use direct whole-buffer forwarding without the startup backlog cap or ordinary-read preferences. The client remains a junction between its borrowed main line and its owned selected child; it cannot remove that line mapping. Server next Init still waits for the first application payload. After Init and replay settle, the server destroys its full line state and permanently bypasses itself; ready ordinary and splice buffers retain their identity through dispatch. See ConnectionFisher retirement.
SOCKS UDP uses ordinary storage throughout: client application input is
materialized before retention, and both nodes materialize every datagram before
header decoding or framing. Client UDP application/control/relay and server UDP
client/remote lines request linePreferOrdinaryReadBoth() on their exact lines.
Client target preparation also requests it before local DNS retains input.
A UDP-only server requests ordinary reads on its TCP control line during Init;
a mixed service requests them when it parses an allowed UDP ASSOCIATE command.
These preferences are permanent and advisory, do not propagate between associated
lines, and never replace handling already-delivered or transformed splice input.
TCP CONNECT does not request an ordinary-read preference.
TrojanClient, TrojanServer, VlessClient and VlessServer also use a 2 MiB logical-byte base budget for their necessary parser state. Input fragments have a byte bound plus bounded parser metadata; do not apply an output-datagram count limit to fragments of one incomplete frame. Empty queued datagrams consume output entries. A parser's active head, cached header and queued sources are charged exactly once.
| Necessary retained state | Byte limit |
|---|---|
| Protocol/order/replay or output backlog in these four nodes | 2,097,152 bytes; 1,024 output/backlog entries |
| Trojan UDP input, including partial-frame/header state | 2,162,950 bytes: 2 MiB plus the 65,798-byte maximum incoming frame |
| TrojanServer initial request assembly | 2,162,950 wire bytes |
| VlessClient TCP response assembly | 2,097,409 bytes: 2 MiB plus 257 response metadata bytes |
| VlessServer request/UDP input, including active head and cached metadata | 2,162,689 bytes: 2 MiB plus the 65,537-byte maximum UDP frame |
| VlessClient UDP input, including pending response/frame metadata | 2,162,689 bytes: 2 MiB plus the 65,537-byte maximum frame |
Equality is accepted. Overflow or failed queue admission settles only the affected flow/association; private-pipe pressure instead uses the shared ordinary fallback. Remove consumed metadata before applying a remaining-body/backlog budget. TrojanServer needs at most 320 request-header bytes and VlessServer at most 278 before accepting or rejecting the request. Incomplete request metadata fits within each node's header cache; a complete header coalesced with body is parsed before the remaining-body budget applies. These logical limits do not measure RSS or descriptor use. Receiving adapters additionally bound entries or allocation charge for decoded datagrams, including empty ones. Mux bounds queue charge for retained stream chunks.
TlsClient's pre-handshake application queue and RealityClient's early application queue follow the default 2 MiB / 1,024-entry bound. They represent a real protocol wait, including application data admitted before TCP Est. Once protocol-ready, their plaintext drains progress without waiting for consumer Resume. Reentrant input stays behind the active plaintext remainder, and active reservations retain their byte/entry charge until consumed and released. Local backlog pressure has its own release path. Keep ciphertext-shaping budgets separate.
TlsServer also bounds pre-handshake backend plaintext to 2 MiB / 1,024 entries. Equality is admitted; overflow closes the affected line and releases its queued buffers. This limit applies even when the handshake deadline is disabled.
TlsClient and TlsServer relay advisory Pause/Resume without gating due record output. Configured record delays, FIFO order, local shaping watermarks and hard limits still apply. TlsClient's plaintext drain can wait for its own shaping backlog to clear; that local scheduling obligation is independent of consumer notifications. Its receive path also drains ready plaintext after flushing TLS output: inbound traffic can retire the final shaped records and cancel the timer that would otherwise wake that plaintext drain. RealityServer already relays notifications through the selected live branch while retaining its authentication, branch-cutoff and Finish guards.
TlsClient and TlsServer use owned-buffer ciphertext BIOs. Feeding ordinary input
transfers its complete sbuf_t on every result, including admission refusal;
empty input is recycled without an entry. Reads advance the owned buffers without
copying into an intermediate memory BIO or moving the unread suffix. TLS library
writes normally copy directly into padded pooled output buffers. Taking output transfers
the allocation, which remains valid after later SSL calls or TLS-state release.
BIO callbacks must never invoke another tunnel: drain owned output only after
the SSL operation returns, with the usual line-reference and reentry checks.
Successful per-line TLS initialization attaches both BIOs to its SSL object
before returning. Later startup failures, including TlsClient's inner ClientHello
generation, can then use normal line-state teardown. Only partial initialization
before that attachment frees BIOs separately.
Each ciphertext BIO admits at most 8 MiB of unread bytes and 1,024 nonempty buffers. Equality is accepted; refusal consumes new input and closes the affected connection through its existing failure path. These are protocol-storage limits, separate from application backlogs and record-shaping storage, not RSS guarantees. Reset and destruction recycle all retained buffers through the BIO's original worker pool. An SSL context and its BIOs have serialized owner-worker access; the context owns their shared BIO method and must outlive them. OpenSSL and BoringSSL compile separate instances of the common BIO implementation and must never exchange library objects. Initial ClientHello delivery still assembles a complete flight for StreamFragmenter; takeover still exposes the exact unread wire tail. ServerHello routing inspects a non-consuming prefix across BIO entries. Neither uses a borrowed pointer into TLS-library storage.
Each main worker SSL context owns an immutable decoded copy of TlsClient's configured ECHConfigList and frees it with the context. ECH-inner scratch contexts have no copy. Ordinary fixed-SNI connections use it and require ECH acceptance; rejection must not release application data, retry the transport, or downgrade to GREASE. Raw arbitrary-hostname ClientHello generation remains fresh GREASE. Preflight measures the configured ClientHelloOuter flight. Handshake takeover bindings retain the emitted outer client random, even when BoringSSL switches to the accepted inner handshake. See configured ECH.
With verification enabled, TlsClient keeps its pinned Trust Anchor ID
intermediate cache in each SSL context. The context owns the immutable
certificate objects. A matching server response permits verification to
supplement the peer's untrusted stack
with those candidates; it never grants trust. Preserve the original root store,
hostname and verification parameters when calling X509_verify_cert(), restore
the borrowed stack afterward, and free only the temporary stack container.
Never add these cached intermediates to the trusted store or enable partial-chain
trust to support certificate elision. Keep the advertised IDs and cached
certificates paired with the pinned source mappings.
TlsClient's per-node, per-worker SSL context owns at most two cached session references. Lookup and ticket callbacks run through explicitly admitted runtime SSL objects on that owner worker; preflight and generated ClientHellos must not consume or populate the cache. Derive the ticket-size limit from the worst-case fresh ClientHello preflight, reserving space for PSK framing and up to 31 bytes of inner padding with real ECH, and discard oversized tickets. Selecting a TLS 1.3 ticket consumes its cache reference before the connection can send it. Before offering a verified session, revalidate its certificates with the current roots, hostname and parameters, using cached TAI intermediates only as untrusted candidates. Discard invalid candidates; if none remain usable, perform a full handshake. SSL-context destruction releases cached sessions after its SSL users are gone; cache operations never retain a line or borrow a worker pool. See session resumption for identity scope, expiry and the static-RSA exclusion.
TlsClient installs an optional BoringSSL record-output reservation hook. Completed TLS 1.2/1.3 handshakes can seal nonempty application records directly into the owned BIO's padded output storage when no control flight or write retry is pending. The ordinary output path handles other cases, including a declined reservation. Reserve obtains writable capacity and any required queue slot before sealing; commit publishes only the actual ciphertext length and cannot allocate or fail. A sealing error cancels the reservation without retrying encryption. New output allocations retain onward headroom and prefer aligned ciphertext bodies; appends preserve existing tail coalescing. The byte and entry limits still apply.
A reservation is exclusive and synchronous within one SSL operation. Callbacks
may query SSL state but must not start SSL I/O, replace its BIOs or output
callbacks, or drain/mutate its reserved BIO before commit or cancellation.
The library retains no output pointer afterward, and outward delivery still waits
until the SSL operation returns. The hook uses the existing record sealer, so
record padding is selected once and control-record ordering remains in BoringSSL.
Its commits bypass BIO_write, including that function's byte counters; payload
accounting follows the owned output buffers. TlsServer's OpenSSL path uses ordinary
BIO writes.
When updating the vendored BoringSSL tree, port the record-padding patch before
tunnels/TlsClient/RadkesvatPatches/record_write_buffer_callbacks.patch, then
apply prefix_symbols.patch after all custom public API patches. Keep both record
APIs mapped in the checked-in include/openssl/prefix_symbols.h; preserve
upstream's internal and architecture-specific prefix headers as well. Normal
builds consume those headers without a prefix generator. Rerun the upgrade
checks in tunnels/TlsClient/my notes.txt. The output patch is incremental
on top of the earlier local patches; successful patch application alone does not
verify record ordering, retry behavior or ownership against the newer library.
An admitted input batch is not a newly allocated array of all decoded outputs. Decode and transfer each complete frame in order, continuing after Pause for the remainder of this accepted input while the exact lines remain live. Hold only an incomplete suffix or data genuinely blocked by protocol/reentrancy. Stop on Finish, malformed input or receiver closure; never exceed a receiver's hard limit just to finish the batch. Separately scheduled or deferred work follows the exact node's role in Part 2; ordinary middle-node output needs no consumer-pause gate.
Raw QUIC carrier and retention
QuicClient/QuicServer have an explicit carrier-mode. datagram maps exactly
one carrier Payload to one complete QUIC datagram without an outer envelope.
stream uses a four-byte unsigned big-endian datagram length followed by its
body. Its shared incremental parser has a fixed four-byte header cache and
bounded partial body; zero or greater-than-1,472 lengths fail before body
allocation. It handles arbitrary fragmentation/coalescing and serialized
reentry. No adapter-type inspection or automatic carrier detection is permitted.
The application side is a byte stream and preserves bytes, not Payload counts.
QUICHE receives borrowed input only during its call. Complete wire packets and
decoded plaintext are staged in independently bounded owned events and delivered
after the driver returns. WaterWall buffers use the exact line's pool and full
chain padding. Framed output is constructed with its own four-byte prefix; both
nodes advertise zero additional left padding and kNodeFlagBlocksSplice.
The default per-backlog limits are 2 MiB and 1,024 entries. Count queued and active application retention together with accepted but unacknowledged engine send data, including the opening byte. The engine uses at most 16 KiB per send chunk and a FIFO ledger of offsets, lengths and allocation charges; only fully acknowledged entries at the front release credit. Partial or out-of-order ACKs cannot free later entries across a gap. Each documented backlog also caps retained WaterWall allocation charge at twice its byte budget. Overflow closes the association rather than discarding arbitrary reliable bytes. These are storage-accounting limits, not a total QUICHE heap/RSS cap.
The fixed stream and connection receive windows are 16 KiB–16 MiB, no greater
than the configured receive/storage budget, with receive autotuning disabled.
The engine permits only application stream 0: the server advertises one incoming
bidirectional stream, the client zero, and both zero application unidirectional
streams. It bounds undecryptable packets at eight. QUICHE rejects more than
16 KiB of buffered CRYPTO receive data per encryption level after processing a
frame. The enabled crypto-send check rejects growth past 16 KiB only when that
level already has unsent data; it skips an empty unsent buffer and excludes sent
but unacknowledged data. This is not a 16 KiB cap on total TLS/CRYPTO retention.
The engine advertises active connection-ID limit two, disables migration, and
retains strict pre-validation amplification factor three. The pinned library
separately caps self-issued active plus retiring IDs at ten and may also retain
the original destination connection ID; the advertised limit does not describe
total CID storage.
Fixed descriptor rings require (max-buffered-buffers + 1) * 24 <= max-buffered-bytes.
Send-ledger backing charges 16 bytes per configured entry; each wire/plaintext
backlog charges 24 bytes per entry, including empty-ring reservations.
The configured maximum emitted datagram size is 1,200–1,452 bytes; ordinary QUIC
packets may be smaller. Accepted framed input is independently bounded at 1,472
bytes. Keep these internal bounds aligned with the production engine when
changing its pinned library or admission mapping.
HalfDuplex setup and direct forwarding
Both HalfDuplex nodes remain exact-line endpoints after pairing. The client maps a borrowed main line to two owned transport lines; the server maps two borrowed transports to its owned main line. Permanent bypass would lose that mapping and Finish cleanup. Their aggregate producer holds and startup replay gates retain the endpoint flow-control responsibilities described in Part 2.
HalfDuplexClient must combine its 17-byte upload intro and complete first input into one ordinary buffer. It advertises 17 bytes of headroom and reuses ordinary input when those bytes fit. Insufficient headroom uses a best-fit allocation with full onward padding. Consume splice input through exact representation helpers, including any resident prefix before its private-pipe body; never treat logical length as pointer accessibility. Its separate download intro is ordinary. Both intros and the publication guard exist before callbacks. Empty input retains the same first-Payload timing. The existing Init/intro FIFO may own splice wrappers and retains its inclusive 2 MiB / 1,024 buffer policy. Later payload in both directions transfers the original wrapper. Application Pause/Resume maps directly to the initialized download child, including repeats. Before that child exists, the client remembers only a pending Pause; an intervening Resume cancels it. Child write pressure remains aggregated separately to control the application source.
HalfDuplexServer must materialize whole deliveries in unknown and waiting-upload states before parsing or retention. Its buffering slot is always ordinary, including body bytes accompanying an intro. Check combined lengths and reserve full onward padding before merging. Pair an available peer before applying the existing waiting-upload limit. Strip exactly 17 ordinary bytes and replay the retained body before direct traffic. After startup replay, upload and return traffic remain representation-blind; download-side extra input is recycled without reading its body. Conversion refusal removes any waiting map entry and releases retained input before finishing the borrowed half through its owner.
The owned server main line publishes startup ownership before next Init: a separate ordinary initial body, an Init/replay barrier, permission state, and a budgeted FIFO for later input. Both transport halves (including a PipeTunnel companion) remain borrowed. Reentrant upload must join that FIFO while the barrier is active, even when the role enum already indicates direct upload. The initial body retains its previous admission and is not charged to the new FIFO budget.
The later-input budget has inclusive 2 MiB logical bytes, 2 MiB canonical allocation charge, and 1,024 entries. Initialize it and attach its queue at their final stable addresses. Empty entries consume charge and entries. Materialize each retained splice delivery fully with checked best-fit storage and onward padding; refusal closes the association. Ready deliveries have no new cap or transport queue. The waiting-map policy and HalfDuplexClient's policy are unchanged.
Replay cannot run inside next Init or recursively inside initial-body dispatch. It waits while next is paused, detaches the initial body before handing it off, then rechecks exact main/upload/download life and associations. Keep the barrier active across that first handoff. Prepare the entire currently queued tail without outward callbacks, reusing a sole entry or coalescing multiple entries into checked ordinary storage. Release queue charges as ownership moves into this bounded scratch. Clear the barrier immediately before the final tail handoff, so any nested direct input follows all older bytes already handed to next. Do not loop through a FIFO that callbacks can continuously refill or require a future input to make progress. Materialization and coalescing overlap are not an aggregate physical-memory bound.
Next-side Pause publishes permission and source notification state before callbacks. Resume during Init/replay records permission without bypassing the barrier. Release source Resume only after older startup output is handed off and next remains writable. Publish notification state before callbacks and retain and recheck all exact association lines. Every close origin releases main-owned initial/FIFO storage, verifies empty budget usage, and detaches associations before outward Finish; internal refusal finishes all initialized affected sides, while a received Finish never reflects toward its sender. The owned main dies before its Finish handler returns. Preserve the existing sibling-close scheduling fallback.
PipeTunnel transfers the wrapper through worker messages without inspecting its body. The already-inspected intro is replayed as ordinary memory on the target worker. Private-pipe bytes survive source closure; canceled messages destroy the wrapper without borrowing a foreign worker's pool. No permanent ordinary-read preference is needed for either HalfDuplex node.
Reverse activation prefix
ReverseServer sends one ordinary prefix per pair: a four-byte big-endian total
length followed by random padding. Length includes the header, ranges from 4 to
65,536 bytes, and is selected afresh with the fast CSPRNG family. Server-only
activation-min-size and activation-max-size default to 64 and 1,024 inclusive;
strict integer validation enforces wire bounds and min <= max. ReverseClient
uses only the received length and protocol bounds, never the configured range.
No fixed magic bytes or recurring application framing are added. Random sizing
is not a guarantee against fingerprinting; a plaintext length and timing remain
observable without an enclosing encrypted transport.
The server builds the prefix in a separate padded buffer and hands it off exactly once even if the reverse consumer is paused. This startup exception applies only to activation, with no buffered user bytes attached. Publish a reentry barrier in both halves before inherited Pause notifications and keep it through the prefix handoff; nested Payload or Resume cannot release application data ahead of it. Finish cancels any unsent prefix. Receiver hard limits still apply, and the next transport may queue activation until it can physically write.
Retained application replay preserves FIFO through reentry and waits for output Resume. Each server half has one budgeted application FIFO with inclusive limits of 2 MiB logical bytes and 1,024 buffers, independent of RAM profile. Waiting data and startup retention share that budget; pop releases its cost before handoff. Nested admission during one prefix handoff or backlog-drain dispatch has the same finite byte and buffer bounds, preventing callbacks from endlessly refilling it. Empty TCP input is recycled without an entry. Refusal closes the waiting half or both paired halves through their owners. The separate activation buffer is at most 65,536 bytes. Reverse handshake fragments are compared incrementally and recycled; only a matched offset is retained. Cross-worker replay coalesces the admitted FIFO behind a synthetic handshake, without outward callbacks during preparation.
ReverseClient caches four header bytes and discards padding incrementally through
sbufReadRangeToMemory() using at most 256 bytes of scratch. Never interpret pipe
metadata as header/body memory, allocate the announced length, or forward padding
to the destination. Validate length before consuming its body. Preserve an
accompanying application's ordinary/private-pipe remainder and forward it after
destination Init; later deliveries transfer intact. Nested application input
while destination Init/initial handoff is active stays behind older data in one
owned FIFO. The original initial body has an active reservation in the same budget
as queued input: 2 MiB logical bytes and 1,024 buffers, with empty input consuming
no entries. Release the reservation or pop cost before handoff. Preserve the first
body's identity and queued private pipes. Nested admission across one initial
dispatch separately has those finite byte and buffer bounds. Close releases the
initial body, its reservation and all queued entries exactly once. These logical
budgets are not allocation-charge or physical-memory guarantees; ready direct
payloads do not enter them.
Client first-payload requests
TrojanClient and VlessClient assemble their initial request and the entire first eligible application payload in one ordinary buffer. Reuse a locally owned ordinary body when the complete request and any first UDP frame header fit its advertised left padding. Otherwise allocate best-fit ordinary storage and append the source with the shared exact range helpers, preserving any existing resident prefix and fully materializing a pipe body. Do not spend another node's padding or leave the body in a splice wrapper for this first combined transfer. Later opaque TCP preserves buffer identity and splice representation where the existing helpers permit it.
Each client exposes first-payload-timeout-ms, a nonnegative integer defaulting
to 400. A valid payload after next Init triggers the combined request immediately,
even before transport Est. If no request has been sent when Est arrives, establish
the deadline then; paused time counts. At expiry, direct TCP submits a header-only
request even during advisory Pause. An owned UDP carrier waits for Resume while
its output is paused. Zero disables deliberate waiting.
An already-available eligible payload is still combined; expiry never forces it
into a second send if the request has not yet been submitted.
Both clients relay repeated TCP Pause/Resume transparently; pressure state remains only for the owned UDP carrier's producer. A synchronous admitted input can complete its combined handoff while that carrier timer waits. Publish request admission before forwarding, cancel the timer, and never send a second header. Empty TCP input does not trigger a request or restart the deadline. UDP eligibility follows the protocol's own datagram rules.
Keep full onward padding in new allocations. TrojanClient advertises 331 bytes: the 68-byte UDP association request plus the largest 263-byte datagram header; its largest TCP request is 320 bytes. VlessClient advertises 280 bytes: its largest 278-byte request plus the two-byte UDP length prefix. UDP first output contains the initial request, one datagram frame header and that complete body. Later datagrams still prepend only their own frame header (at most 263 bytes for Trojan and two for VLESS). Header-only timeout output has ordinary best-fit storage with onward padding. No queue or extra timer is needed just to await Est or a peer response before outbound submission.
First-buffer construction can transiently add the actual request/frame headers to a selected retained payload's size. That finite construction overhead is not permission to hide a second retained output queue. Nested clients add headers and repeat assembly; a normal 1 MiB splice delivery plus 64 maximum-size Trojan headers adds only 20 KiB, within the 2 MiB retained-input base budget. One ordinary output buffer does not promise one syscall, TCP packet or TLS record.
TrojanClient variable headers and opaque TCP
The initial request is 68 bytes for IPv4, 80 for IPv6, or 65 plus domain length,
at most 320. UDP association uses 0.0.0.0:0, a 68-byte initial request. The first
combined UDP output also includes ATYP ADDR PORT LEN CRLF for its datagram.
Subsequent upstream datagrams are wrapped inline; insufficient headroom uses
sbufMoveRangeTo() with full onward padding and preserves its returned pointer.
Trojan address inspection uses an allocation-free owning value with explicit incomplete/complete/invalid results; failed decode or capacity-checked encoding leaves outputs unchanged. Command, CRLF, port and datagram-size policy remain with the caller. Validating a reply source creates no owning routing context and never changes the application's destination.
UDP receive caches at most 263 header bytes: 11 for IPv4, 23 for IPv6, or 8 plus
domain length. Consume only required metadata with sbufReadRangeToMemory().
Validate address, nonzero port and CRLF before extraction. The two-byte body length
accepts the full 0..65,535-byte range; the frame adds at most 263 header bytes.
Never retarget the application from a received source address. A zero-length body
is a valid datagram, including as the first application payload. Oversized local
UDP above 65,535 bytes is dropped without triggering the first request; malformed carrier frames close
that association. Complete current-input datagrams continue after Pause; only an
incomplete suffix or a genuine protocol/ordering barrier is retained.
VlessClient response transition and UDP framing
The initial request is 26 bytes for IPv4, 38 for IPv6, or 23 plus domain length,
at most 278. Target preparation establishes a valid address and nonzero port;
Init saves a private copy that remains unchanged until cleanup. The request
encoder can assert those preconditions. Keep its checked allocation for the
combined request and first body: an arbitrary TCP delivery can still exceed
representable buffer geometry after adding headers and padding.
UDP frames contain a two-byte big-endian body length and no address.
Bodies are 0 through 65,535 bytes. Local oversized datagrams are dropped without triggering the initial request.
An empty datagram emits a 00 00 length prefix and triggers the initial request when it is the first application
payload. A received zero length consumes its two-byte prefix and delivers exactly one empty UDP datagram; keep the
association open and continue parsing the next frame in order.
This differs from Xray's base VLESS implementation, whose writers drop empty datagrams and whose reader skips zero-length frames.
Empty-datagram delivery requires a peer that supports it; nonempty framing is unchanged.
Empty datagrams are valid at the UDP layer (RFC 8085 §5).
Response parsing caches at most 257 metadata bytes. Consume exactly the two fixed response bytes and the indicated addons before body delivery. Version must be zero; nonempty addons are accepted/skipped with the existing warning. TCP then stops interpreting headers; preserve a coalesced body in its representation without waiting for Est or building a separate ready-output queue. UDP gathers its exact length header and extracts one complete body at a time, including every complete frame in the accepted input after Pause. Retain the incomplete suffix.
After response completion, nested TCP body input uses the 2 MiB / 1,024-entry ordering bound, including any still-local active head once. Publish admission before callbacks and release accounting before body transfer. This entry limit does not apply to fragments of an incomplete UDP frame or to ready synchronous TCP forwarding.
Whole suitable bodies transfer directly. Split/mixed bodies use shared exact range movement and preserve the returned replacement pointer, unrequested suffix and full onward padding. Mutate only a popped, locally owned active head. Metadata, active input and queued sources remain charged once until consumed.
TrojanServer request inspection and retained wire bytes
Initial parsing caches at most 320 resident header bytes. Preserve authentication and fallback policy: the first callback's logical length must include all 56 hash bytes, even if splice-backed; validate available hex/CRLF before authentication and cache successful lookup. Post-authentication malformed input closes the protected flow. Rejected unauthenticated input replays every inspected byte before its untouched tail, with replay ownership secured before fallback Init. Do not read opaque TCP body merely to fill a maximum-sized header cache.
After selecting and initializing CONNECT, ordinary body/reply forwarding is inline; retain only input needed to preserve ordering across branch Init or nested callbacks. UDP stages address/port/length/CRLF with at most 263 metadata bytes and accepts 0..65,535 body bytes in both directions. A complete current-input datagram is submitted through its selected backend even after Pause; the receiver owns bounded tolerance. Keep only incomplete input or actual initialization/protocol-ordering state. Replies preserve the same 65,535-byte body maximum and prepend at most 263 bytes.
The Trojan body bound is a framing capacity, independent of UDP socket/path or peer limits. Ordinary IPv4 UDP payloads reach at most 65,507 bytes without IP options. Preserve each accepted datagram intact: a frame may span carrier reads, but neither endpoint truncates the application body or splits it into separate UDP datagrams.
TrojanServer retains only cached address metadata while awaiting a UDP body. It constructs a routing context only for a new backend or a TCP target; reusing a backend needs no transient domain ownership. Each remote separately owns its immutable lookup key. New destination-domain storage follows the fatal heap-allocation policy. Address encoding checks its complete extent before writing and leaves splice bodies untouched.
A necessary association reply FIFO accounts encoded wire bytes and datagram entries under the 2 MiB / 1,024 limits. Live initialized backend replies have no Est waiter. Immutable replies already accepted into the client FIFO belong to that FIFO, not to a borrowed backend pointer. Its independent drain honors Pause; inline forwarding of an already-admitted backend reply follows the synchronous batch rule.
Delayed fallback retains a real bounded backlog, including inspected replay bytes. Its scheduled drain and narrow source-Finish final batch use exact representation-aware assembly, sole-buffer transfer where suitable, and complete ordinary fallback. Those independent/final drains preserve FIFO, completed branch Init and no-reflection obligations; advisory Pause does not block a due or final batch.
VlessServer request inspection and replay
VlessServer borrows the client normal line and owns only its fixed-destination UDP backend. It caches at most 278 request metadata bytes, consuming only the next required field through shared range helpers. The first callback must contain 17 logical bytes (version plus UUID), regardless of resident/pipe boundaries; shorter or empty input takes fallback/reject. Cache successful authentication. Nonzero request addons remain unsupported once their advertised bytes arrive; post-authentication malformed input closes rather than replays. Remove accepted metadata before body budgeting.
One guarded mixed-input FIFO and popped active head preserve old body bytes across Init and nested input. TCP output and fallback replay share a 2 MiB / 1,024-entry upstream budget; the response-header/reentry FIFO has its own equal budget. Attach populated parser storage transactionally only when it becomes output. Parser bytes include cached metadata, active head and queued sources exactly once, without an output-entry limit on incomplete UDP fragments.
Fallback restores all inspected bytes before untouched tails before branch Init. No drain or final batch runs inside Init.
Zero-delay admitted handoff follows synchronous input rules; due delayed or independently retained replay does not wait for Resume.
Sole suitable buffers transfer directly; mixed batches use exact range movement with complete ordinary fallback.
UDP gathers exactly two length bytes and extracts 0..65,535 body bytes with full onward padding. Zero delivers exactly
one empty datagram and leaves the association open; continue with the next frame. Body extraction leaves
following frames untouched. Complete accepted frames continue after Pause. UDP replies prepend a two-byte length prefix;
insufficient headroom uses shared movement, preserving any resident prefix and private body. Empty replies emit 00 00;
oversized local replies drop without closing the backend. Send the 00 00 response header exactly once, combined with
the complete first nonempty TCP reply or first framed UDP reply in one ordinary buffer. An empty first UDP reply emits
00 00 00 00, containing the response header and datagram length. VlessServer advertises four padding bytes for the response header plus the UDP
length prefix on the first datagram. Reuse ordinary input in place when its remaining headroom fits the response header.
Otherwise allocate a best-fit ordinary buffer with full onward pool padding and copy through shared range helpers,
materializing any private-pipe body. For UDP the combined buffer also contains the datagram length prefix. Later replies retain their
normal representation. Est, Resume and empty TCP input must not generate a standalone response header; no timeout or Finish
flush emits one. Publish the response-sent latch before the combined handoff to preserve FIFO under reentry.
Est remains prompt and independent of reply availability and output Pause.
VmessCommon and VmessClient ordinary records
tunnels/Internals/VmessCommon owns the shared VMess KDF, prepared UUID keys,
AuthID/header operations, mode validation, response derivation and persistent body
codec. Server credential vectors, candidate searches, authentication pumps and replay
tables stay in VmessServer. Explicit timestamp/entropy inputs make client seals
deterministic in independent vectors; production draws fresh request material at its
first transmission commit. Mutable cipher/SHAKE contexts stay on the line's worker;
temporary, session and node-owned key material is wiped at its lifetime boundary.
Request plaintext is at most 316 bytes and its full envelope at most 374 bytes. The client authenticates the fixed 18-byte response length before retaining a response plaintext of 4..259 bytes, at most 293 wire bytes including both tags. Validate the expected marker and exact outer command length, consume bounded options/commands without applying remote account changes, and preserve coalesced body bytes. The server continues to emit its ordinary 38-byte response. Response authenticated-length keys and nonces use request key/IV; response body encryption and SHAKE use derived response key/IV, matching the pinned peer's independent directional material.
VmessClient blocks splice for the expanded chain and advertises zero required left padding. Allocate ordinary output with full onward padding, combining the request with the first encoded body record. TCP records use at most 16 KiB plaintext; UDP uses one record per datagram, with empty local datagrams retained only in packetaddr mode. Reuse the codec's maximum-frame arithmetic: tags, authenticated-length offsets and worst-case padding all count. Drop native empty/oversized local UDP without splitting or request emission. An empty framed record is termination; verify its encrypted tag before accepting EOF.
Each client direction has inclusive 2 MiB logical, 1,024-entry and 8 MiB allocation charge limits. Active popped buffers and incomplete fragments retain reservations; compact fragments when that reduces retained storage. Release reservations and owned buffers exactly once on handoff, refusal or close. Separate TX/RX guards retain input only for incomplete parsing or FIFO ordering; complete decoded records are delivered incrementally without a pressure-only output queue. Server unauthenticated input stays limited to 64 KiB: a large early plaintext burst can exceed that wire allowance after framing during identification. No response acknowledgement barrier, implicit pacing or enlarged server limit hides that admission refusal.
Both endpoints explicitly pass their immutable encrypted-counter-policy to each
body context. Default wrap permits data at all ordinals 0..65,535 and continues
65,535 to 0 under the same session key. This deliberately repeats the 16-bit AEAD
nonce sequence for interoperability: it is not rekeying and does not preserve nonce
uniqueness. strict reserves output ordinal 65,535 for EOF, delivers valid incoming
data at that ordinal and then closes before another nonce use. NONE/zero are unaffected.
The policy is local with no negotiation; a strict peer can close a wrap peer at its limit.
Advance once only after a successful record, keeping body and authenticated-length nonces on the same ordinal. Preserve pending length/padding state across partial input. SHAKE masking/padding continues across rollover without resetting session material or response parsing. Authentication failure and EOF remain terminal; never retry using another ordinal or policy. In wrap mode the counter is bounded modulo 65,536; strict retains the 65,536 exhaustion sentinel. No reconnect or automatic replay is introduced.
Packetaddr uses ordinary UDP command 2 with the exact outer domain
sp.packet-addr.v2fly.arpa and port 0. This is the only zero-port request exception;
never route that marker to DNS or an outbound connector. Every authenticated body
record contains envelope family 0x01/IPv4 or 0x02/IPv6, its network-order address,
a big-endian nonzero port, then exactly one datagram. The envelope occupies 7 or
19 bytes, which must be subtracted from the body-mode maximum application payload.
It carries no domain encoding. Validate the complete envelope before routing and
never split a datagram. A header-only envelope is an empty application datagram;
an empty outer framed record is still EOF. Native UDP keeps its empty-drop/EOF rules.
Client packetaddr retains one immutable final IP/port and one owned carrier. Explicit local DNS may supply that IP; an unresolved domain is refused before transport Init. Unexpected reply sources are dropped/counted rather than delivered to another association. Server packetaddr owns one bounded destination backend per IP/family/port, and encodes replies from that immutable backend target. Queued encoded replies own all envelope/payload bytes independently of backend lifetime. Existing per-direction byte, entry and allocation budgets remain unchanged by the destination-count limit.
VmessServer ordinary records and bounded retention
VmessServer blocks splice for the complete expanded chain. Its borrowed normal carrier owns incremental authenticated-header and record parsing; each native or packetaddr UDP backend is a separately owned normal line. Never copy VLESS's initial-callback UUID requirement or successful TCP bypass into VMess: its AuthID/header may be split at any byte, and encrypted records remain active for the connection lifetime.
The AEAD request prefix is 42 bytes, followed by at most 316 plaintext-header bytes plus a
16-byte tag. Validate the authenticated length before allocating or opening that header. Decode
record lengths in wide arithmetic: authenticated-length framing can represent 65,535 plus its
16-byte offset. Keep parser length/padding state until exactly that body is consumed. Empty
framed records terminate the protocol and must not become empty UDP datagrams; authenticate an
encrypted terminal record before accepting it. Raw none (zero client configuration) is TCP
only. A UDP reply must fit one record; oversized replies are dropped without splitting.
Native UDP drops empty replies; packetaddr encodes their nonempty address envelope.
Each unauthenticated carrier retains at most 65,536 logical input bytes and 256 buffers under
the node-wide auth-pending-max-bytes allowance. Consumed AuthID/header bytes remain charged to
that allowance until authentication or fallback selection settles. Per-direction ordering and
fallback queues have inclusive 2 MiB logical, 1,024-entry and 8 MiB allocation-charge bounds.
Popped active buffers retain their reservations across callbacks. Incomplete bodies have one
bounded ordinary allocation; completed bodies are handed onward before parsing later records.
No queue is maintained solely because an ordinary TCP consumer sent advisory Pause.
The first response includes exactly one encrypted response header before its body.
Both directions follow the configured wrap/strict policy above; unencrypted bodies
have no AEAD counter limit. Terminal closure follows WaterWall's full-close model and
does not promise delivery of unsubmitted application replies at strict exhaustion.
send-termination=false suppresses only optional outgoing empty framed EOF; valid
incoming EOF remains authenticated and accepted, and raw zero has no framed EOF.
Fallback restores every inspected byte before untouched carrier input and secures replay ownership before branch Init. Only unknown credentials or unavailable authentication before recognition can select fallback. Recognized time/replay, integrity, command/option and resource failures never do. Due fallback replay proceeds through advisory Pause; source-Finish final replay follows the same finished-side and reentry rules as VlessServer.
MUX parent streams and decoded child queues
Mux uses this exact eight-byte wire header, separate from decoded host fields:
| Offset | Size | Meaning |
|---|---|---|
| 0 | 3 bytes | Unsigned payload length, big-endian. |
| 3 | 1 byte | Frame type/flags. |
| 4 | 4 bytes | Unsigned connection ID, big-endian. |
The inclusive payload maximum is 1 MiB (1,048,576 bytes) for every frame type, independent of RAM profile, buffer sizes and requested or granted pipe capacity. A maximum frame occupies 1,048,584 wire bytes; a preceding Open adds another eight bytes (1,048,592 total). Decoding rejects a larger declaration as soon as all eight header bytes arrive, even for unknown CIDs or types, and closes only that parent through normal parent-loss cleanup. After a valid Data header, the decoder delivers available body bytes incrementally; it does not wait for the whole frame. Data for an unknown or closed CID is discarded incrementally. Empty Data is consumed without a child Payload callback. Other frame bodies are also consumed incrementally, but control effects wait until their declared bodies end. Available body bytes and completed controls drain before retained incomplete input is checked against 1,048,584 bytes, including its cached header. Bytes not yet received do not count as retained input.
Open=0, Close=1, FlowPause=2, FlowResume=3 and Data=4 retain their meanings; child CID
uses the full 32-bit range. Ping=5 and Pong=6 are parent controls independent of
child lookup. Their payload is empty and CID bytes carry a 32-bit probe token,
echoed unchanged by the responder. Each control occupies eight wire bytes;
Data framing and splice representation remain unchanged. Disabled keepalive
consumes controls without responding or acknowledging. The serializer uses mux_wire_header_t; decoded
mux_frame_t has a 32-bit length and must never be copied as wire bytes.
Each child is an ordered byte stream with arbitrary Payload boundaries. Both Mux
decoders track the current Data CID and remaining body length, publishing progress
before child callbacks. Extraction returns only available body bytes: do not shift
away another eight bytes. One Data frame may produce several callbacks and paused
children may retain several queue entries for it. Empty Data creates no callback
or child queue entry. Data for a missing or closing child is discarded as bytes
arrive. If delivery closes the child partway through a frame, discard the remaining
body before decoding the next header. Look up the child again for each chunk;
do not cache its state pointer in the frame state. Parent receive reentry appends behind current input
and observes the already-published frame progress. After callbacks, physical
references protect allocation life and logical life/state checks decide whether
parsing may continue.
MuxClient owns normal parent lines and borrows children. MuxServer borrows parents and owns children created on Open. Splice support changes neither direction nor Finish/lifetime rules. Available Data chunks and complete controls drain before checking the logical incomplete-carrier overflow limit. If a parent fails halfway through Data, already delivered bytes stay delivered and accepted queued chunks retain their usual detached drain; the missing suffix is not reconstructed. Later frames still follow the current frame's body on the shared carrier, so incremental delivery does not eliminate transport head-of-line blocking. MuxServer treats each accepted nonempty Data chunk as child idle activity; no whole-frame wait delays the initial-to-active timeout transition or its refresh.
MuxServer reserves each admitted child in one atomic count across all parents
and workers until its line state is destroyed, including detached and draining
children. max-live-children defaults to 0, disabling the configured aggregate
count ceiling. A positive value enforces that ceiling before allocation.
max-children remains a positive per-parent limit. A nonzero aggregate ceiling
must be at least both max-children and memory-fallback-max-live-children.
Fresh cached memory data still applies the configured high/low pressure
watermarks and byte reserve when the aggregate count ceiling is disabled.
Unavailable, unsupported, or stale memory data instead applies the positive
memory-fallback-max-live-children ceiling, including when max-live-children
is 0; a positive aggregate ceiling also remains in force. Nonfresh data cannot
reopen a gate closed by fresh memory pressure. These gates affect new Opens;
the separate queue-capacity budgets below continue to bound retained buffers.
Queue-capacity budgets
Mux uses one queue-capacity charge for every owned buffer:
sizeof(sbuf_t) + sbufGetTotalCapacity(buf) + kSbufAllocationAlignment
Capacity includes reserved left padding. Ordinary entries therefore retain their allocation-based charge; splice entries charge logical sbuf capacity, without adding wrapper control storage or kernel pipe capacity. Inventory accounting is independent of this queue charge and does not force a valid retained source into ordinary storage.
The sbuf header and alignment overhead are each 32 bytes. For example, after a read delivers 100 body bytes into a splice wrapper with 64 bytes of left padding:
| Value | Bytes |
|---|---|
sbuf->len, before any prepend | 100 |
sbuf->capacity, including padding | 164 |
Queue-capacity charge: 32 + 164 + 32 | 228 |
The charge stays 228 bytes even if the private pipe supports 1 MiB. This formula
does not add kernel capacity or the wrapper's control storage. Use
sbufGetAllocationCharge() when the question is the wrapper's actual userspace
allocation instead. An ordinary buffer holding 100 bytes still charges its full
allocated capacity, which can be larger than its length.
The boundaries below have different scopes and units. A parent is one Mux carrier connection; its children are the streams multiplexed through it.
| Boundary | Default | Scope and behavior at the boundary |
|---|---|---|
| Splice socket read request | 1 MiB in every profile | Upper bound per read attempt. A positive short transfer produces a shorter valid buffer; remaining bytes wait for a later read. |
| Payload in one Mux frame | 1 MiB | Independent protocol maximum. A larger declaration closes that parent as soon as its complete eight-byte header is decoded. |
| Retained incomplete input | 1 MiB + 8 bytes | Received logical bytes, including the cached header. Available Data chunks and complete controls drain first; an oversized remainder closes the parent. Unreceived body bytes do not count. |
child-buffer-limit | 128 MiB charge | One child's queued decoded data. Reaching the limit closes that child. |
parent-buffer-limit | 512 MiB charge | Incoming stream plus all attached child queues on one parent. At the limit, try beneficial incoming compaction, then shed largest queued children. Unrelieved pressure closes the parent. |
parent-write-buffer-pause-threshold | 128 MiB charge | One outgoing parent FIFO. Reaching it pauses child producers while the FIFO drains. |
parent-write-buffer-resume-threshold | 4 MiB charge | Release the outgoing producer gate at or below this charge while transport is writable. |
parent-write-buffer-limit | 512 MiB charge | One outgoing parent FIFO, including admitted frame batches. Equality is allowed; exceeding it refuses the new output and closes that parent. |
detached-buffer-limit | 32–256 MiB charge, by RAM profile | Per Mux instance/worker, for blocked child queues retained after parent loss. Reaching it closes the newly detached blocked child. L2 defaults to 256 MiB. |
detached-child-limit | 4,096–12,000 children, by RAM profile | Count of those detached blocked children on one Mux instance/worker. Reaching it closes the newly detached blocked child. L2 defaults to 12,000. |
The named queue settings are JSON-overridable. parent-buffer-limit: 0 disables
the combined receive limit; it leaves individual-child and protocol limits in
force. The two detached limits also support 0 independently. Outgoing pause and hard limits
stay positive; resume may be zero, with resume < pause < hard. The read size follows the
pool/RAM profile, while the Mux frame maximum remains independent of it.
See the MuxClient and
MuxServer references for complete settings and
profile tables. Connection-count, idle-timeout, and server admission settings
remain separate from these buffer boundaries.
parent-buffer-limit (512 MiB by default) covers incoming frame assembly plus
all attached child queues. The stream counter remains separate from the child
aggregate, and their sum is checked at stable return boundaries. Available Data
chunks and complete controls in a coalesced delivery drain first, even if its
temporary charge exceeds the limit. A paused child retains chunks as they arrive,
so one Data frame may create several charged queue entries. Cached header bytes
count toward the independent 1,048,584-byte incomplete-input bound until the body
ends, but the header cache and stream/queue metadata carry no sbuf charge.
The declared body bytes still missing from the transport carry no retention charge.
After an ordinary receive pass drains available input, a partial Data frame needs
only its cached header and remaining-length state between arrivals. Any accepted
chunks for paused children belong to their queues, not to frame assembly.
Active splice-head body consumption reduces logical capacity and its charge;
prefix consumption and ordinary cursor movement do not reduce capacity.
parent_receive_charge = incoming_stream_charge
+ sum(attached_child_queue_charges)
Each child reads its charge from its queue. Attached queues bind to a budget in
the stable parent-only state; parent loss rebinds them to their worker's detached
budget before any outward callback. Both budgets use SIZE_MAX in every
dimension for accounting; the Mux policies below still decide admission and
closure. Detached child count remains separate from queued entry count. Ordinary
pop releases queue and aggregate usage before delivery, including during
reentrant callbacks.
Reaching a finite receive limit first attempts to combine incoming fragments into one ordinary best-fit buffer, only if its predicted total charge is strictly lower. Compaction preserves the cached header and exact byte order. It does not run on every Push or rewrite child/output queues. Remaining pressure sheds the largest queued child, preferring the oldest on ties, and rechecks after every callback. Several victims may be needed. No victim or no progress closes only that parent through normal parent-loss cleanup. A zero parent limit disables this combined finite bound; it introduces no other aggregate cap.
child-buffer-limit remains 128 MiB by default and rejects equality. Outgoing
parent queues remain separate: their default pause threshold is 128 MiB, resume
threshold is 4 MiB, and hard limit is 512 MiB; hard-limit equality is permitted.
Detached queues retain their per-worker settings and zero/unlimited meanings.
The FlowResume threshold defaults
to 512 KiB and counts logical payload bytes; FlowPause follows the local child pause state. Ordinary
child candidates may still compact to a cheaper pooled tier; valid splice
candidates are not individually materialized for queue pressure.
There is no worker-wide pipe-count or pipe-capacity quota. Other parents cannot force fallback through shared pipe reservations. These charges are not physical memory, FD, kernel page-reference, socket-queue or RSS bounds, nor a strict instantaneous memory ceiling. More retained splice data can use more descriptors and nominal kernel capacity under the same logical budget. Pools may cache empty pipes after recycling; lower charge does not prove those resources were closed.
sbufTryGetQueueCharge() checks the scalar formula, including 32-bit overflow;
sbufGetQueueCharge() is the internal representable-result helper.
splicestreamCharge() exposes source ownership, while splicestreamCompact()
performs the explicit beneficial pressure operation. Queue entries are immutable;
the mutable active head reconciles its charge after every consumption. Extraction
settles source charge before a result is forwarded or charged to a child queue.
Parser depth defers combined enforcement across nested delivery until available
body bytes and completed controls have drained. Pressure cleanup prevents
recursive enforcement and requires progress after Close.
Mux allocation and data flow
For a Data frame addressed to a live child, the receive direction from the carrier socket follows this path:
Incoming-stream sbufs and all attached child queues share the parent's receive budget. A forwarded result leaves Mux ownership; socket-adapter write queues have their own ownership and are outside this Mux budget.
| Object or storage | When allocated or obtained | How it is released |
|---|---|---|
| Worker buffer pools and caches | Runtime/worker setup; ordinary tiers and splice wrappers refill through their pools. | Worker/runtime teardown after their users have drained. |
| Parent-only Mux state | Parent initialization. Includes the child map, outgoing queue, and incoming stream. | Normal parent-loss/Finish cleanup. |
splice_stream_t and its eight-byte header cache | One allocation per parent, with the cache trailing the stream object. | Parent stream destruction; the cache is not a separate sbuf. |
| Child state and pending queue | Child initialization. The queue requests initial room for eight entries and can grow. | Child cleanup, or completion of its detached drain. |
| Ordinary sbuf or splice wrapper | Checked out from the owning worker's pool when data or framing needs it. | Forwarded to its next owner, recycled when consumed/discarded, or physically destroyed by pool policy. |
| Private pipe pair | Created on demand within the global ceilings and exclusively leased when its wrapper is checked out. | Consumption/discard returns the lease to the shared inventory. Healthy pipes close only at final inventory teardown. |
Opening a child does not allocate a pipe for it. Queue-entry storage grows as needed; the initial eight-entry reservation is not a maximum number of buffered chunks. Each splice wrapper still owns its own pipe exclusively.
-
Read into an owned buffer. The event loop checks out a wrapper containing the normal 32-byte sbuf header, left padding, and 32 bytes of control storage. It moves socket bytes into the private pipe before handing the buffer to a callback. Only actual positive progress becomes its logical length/capacity. Ordinary fallback instead obtains resident storage from the buffer pool.
-
Gather the Mux header. The incoming stream takes ownership and physically consumes only the missing header bytes into its eight-byte cache. Three bytes arriving first, then five bytes plus part of the body, complete the header without requiring the body to become resident. An exhausted source is recycled; any remaining body stays in its buffer or pipe while waiting.
-
Extract available Data bytes. A complete header is required, but the whole body need not have arrived. Consume at most the current frame's remaining length and publish the new remainder before callbacks. A suitable whole source buffer can transfer directly. A chunk spanning inputs or using part of an input gets an independent result assembled in order. Pipe bodies can move pipe-to-pipe; resident prefixes and ordinary sources copy or write as needed. Use the returned buffer because ordinary fallback can replace the proposed destination. Keep the current header cached until its last body chunk is consumed.
-
Deliver or retain for the child. Writable children receive the result immediately. Paused children retain the chunks in FIFO order; a wire frame can occupy several queue entries. Source charges settle before the result is forwarded or added to a child queue. A split source remainder and a retained output each contribute their own charge; a whole-buffer handoff is not charged twice.
-
Enforce receive retention after available input drains. Mux adds incoming stream charge to attached child queue charge. Under pressure, only a strictly cheaper ordinary representation justifies combining incoming fragments. After any compaction, remaining pressure follows the largest-child/oldest-tie and parent-close policy in the limits table. Compaction preserves the cached header. It does not run on every Push or rewrite existing child/output queues. Ordinary child candidates retain their separate smaller-pool compaction behavior; a large kernel pipe alone does not cause a splice child buffer to be materialized.
-
Encode the opposite direction toward the parent. A fitting child payload receives its frame header in reserved left padding and can go directly to a writable parent when ordering permits. Paused admitted output, including Pong replies, shares one FIFO and charge limit inside Mux. New probes remain due until direct handoff is possible. Pops release charge before forwarding. A blocked writer is paused after admission; the outgoing pause threshold additionally pauses all children. Local holds resume at or below the resume charge threshold while the parent is writable, respecting other pause reasons. Large splice inputs are staged as complete ordered batches; admission precedes callbacks and Open precedes the first Data frame. A failed batch cannot publish just its first frames.
-
Consume, recycle, or finish. Once forwarded to an adapter, a buffer leaves Mux's queue accounting. Socket writes may send only part of its real prefix or pipe body; the write queue owns the remainder. Recycling discards unread body bytes and may cache a healthy empty pair. A wrapper has no source-socket reference, so closing its original reader cannot invalidate its private body. Parent loss discards its incoming stream and outgoing FIFO; eligible already decoded child queues can finish under the detached-child budgets.
Mux buffer edge cases
| Situation | Result |
|---|---|
| Fixed inventory is exhausted at socket read | Checkout recycles the unused wrapper and ordinary socket reading takes over for that delivery. Splice mode remains available for later reads. |
| Lazy pipe creation or sizing fails | Reject the candidate, retain earlier full-sized acquisitions and stop that refill. A shared one-second cooldown bounds retries; ordinary fallback handles exhaustion. |
Socket-to-pipe splice returns EAGAIN or zero | Recycle the empty wrapper and try an ordinary nonblocking receive. That receive distinguishes no-data-yet from EOF and handles TCP urgent-data boundaries. |
| Destination pipe fills during frame assembly | Return a complete ordinary fallback, preserving bytes already moved and appending the remaining source bytes exactly once. |
| A read of claimed private-pipe bytes is short or interrupted | Accumulate positive progress and retry EINTR. EOF, EAGAIN, or another failure before the claimed range is complete remains a fatal invariant. This is different from a short socket read. |
| One small splice buffer would become a larger pooled ordinary buffer | Skip pressure compaction when it would not strictly reduce total incoming charge. Kernel pipe capacity alone does not justify conversion. |
| One delivery contains complete frames and a partial Data body | Drain available Data bytes and complete controls before checking combined retained receive charge. The limit is not an instantaneous ceiling on the current delivery. |
| A configured parent budget cannot fit retained incomplete input and child queues | After beneficial compaction and available child shedding, close only that parent; do not loop indefinitely or abort the process for ordinary limit pressure. |
| A child dies while its encoded data is queued to the parent | The parent still owns the admitted output and preserves its ordering until sent or discarded by parent cleanup. |
| Parent loss interrupts Data after some chunks were accepted | Delivered prefixes remain delivered. Eligible queued chunks detach and can drain independently, subject to their per-worker charge/count limits. The parent's remaining input and outgoing data are discarded. |
| A callback closes a child or parent during delivery or pressure recovery | Hold the required temporary line reference and recheck logical life/state before continuing. A reference protects allocation life, not permission to use destroyed state. |
| Empty parent input or a zero-length Data frame | Empty incoming sbufs recycle. A zero-length Data frame is consumed without a child Payload callback or child queue entry; it is neither EOF nor Close. |
The queue-capacity formula and ownership transitions do not promise that physical RAM, descriptors, or fixed inventory capacity fall when a charge is released. Runtime I/O failures and the fatal claimed-byte invariants remain separate from configured queue overflow and its connection-level cleanup.
MUX outgoing parent queues
Inputs from zero through 1 MiB inclusive keep the in-place single-buffer path; zero emits one Data header. The frame cap limits payload, not a local callback. Large ordinary inputs keep the ordinary aggregate encoder. A large splice input, including a real resident prefix, splits into independently owned Data bodies of at most 1,048,576 bytes. A real 1 MiB pipe body plus a resident prefix can exceed this cap and still requires splitting. The encoder stages the complete batch before any callback, with Open before first Data for a new client CID. It uses the shared exact range-transfer fallback; no temporary stream is allocated around one encoder input.
Batch preflight includes checked wire/frame counts, existing parent backlog, staged queue-capacity charges. Pipe estimates use completed logical sbuf capacity, not the empty wrapper or nominal kernel capacity. Reserve the remaining frames' ordinary allocation costs before selecting expensive pipes, so greedy pipe choices cannot refuse a batch that fits as ordinary. Reserve all parent FIFO entries before publication. Failure recycles the whole local batch and remainder, publishes no Open state or output charge, and closes only the affected parent.
After admission, all frames belong to the parent. Publish wire state before gate notifications or payload callbacks. The guarded pump preserves FIFO, including Ping/Pong, and stops output immediately on Pause. Resume continues the admitted remainder; nested payload or reentrant child Close queues after it. Parent death discards undelivered frames. Quiescence admits no new stream work, child, batch or protocol output.
parent-write-buffer-pause-threshold defaults to 128 MiB,
parent-write-buffer-resume-threshold to 4 MiB, and
parent-write-buffer-limit to 512 MiB of queue-capacity charge. Pause and hard
accept strict integers in [1, INT_MAX]; resume accepts [0, INT_MAX].
An omitted resume defaults to 4194304 (4 MiB) independently of the effective
pause threshold; explicit values are honored.
Require 0 <= resume < pause < hard without clamping; diagnostics name all three
effective values. Zero requires an empty outgoing queue and writable transport;
a positive resume threshold permits release before the queue empties.
Every split splice batch must fit the hard limit even when the transport is writable;
unusually small limits can therefore reject a large splice delivery before any
frame is sent. The existing fitting single-buffer direct path remains eligible
for immediate delivery without FIFO admission. Gate release occurs at or below resume while writable, before selecting the
next buffer and only after the previous callback has returned. Keep the pump
guarded during fanout so reentrant output follows the FIFO. Renewed pressure
interrupts Resume; the intrusive FIFO of held children releases only that subset,
even if the aggregate gate never activated. A writer held again joins the tail.
Snapshot references protect every member before callbacks, and current line life,
attachment and mux state are checked before use. Startup completion reconciles
reasons separately from emitted permission. Detach and terminal transitions
remove membership before destruction; cleanup does not send Resume. Peer-flow and terminal-close reasons stay
independent. Quiescence cannot release producers.
Individual holds are applied after complete ordinary/splice admission while the transport is paused, or when delivery leaves it paused. Control submissions alone do not acquire writer holds. Neither this policy nor a new child changes the hard-limit accounting or FIFO ownership. Check low water after delivery returns, not between subtracting a popped buffer's charge and delivering it. A release that raises charge above low water stops; writable draining supplies the next opportunity. An empty writable FIFO also releases ready held children.
These are per-parent retained charges, not physical memory or transport capacity. The thresholds do not promise continuous sender output or increased carrier bandwidth. Already-admitted Data is not withdrawn by peer FlowPause; the default 128 MiB child and 512 MiB aggregate receive budgets still apply. A zero-child ordinarily exhausted client parent waits for its final FIFO frames to leave before orderly close; crossing resume is not a terminal drain barrier. Watchdog-expired idle parents instead discard blocked final output and close without waiting for Resume, including after their last attached child leaves.
MUX children carry ordered bytes; neither callback nor wire-frame boundaries are preserved. Put UdpOverTcpClient before MuxClient and UdpOverTcpServer after MuxServer when carrying UDP datagrams. UdpOverTcp currently requires nonempty datagrams.
Stream delivery headroom and datagram framing
ConnectionToPackets retains at most P + L pending TCP payload bytes per flow,
where P is its configured max-pending-bytes and L is fixed at 1 MiB,
independent of pool sizes, RAM profile and splice settings. This headroom bounds
already-delivered input before Pause can take effect. Its 1,024-entry cap remains independent, and overflow is rejected
before queue insertion. PacketsToConnection applies the same P + L bound on
the return path; its acknowledgement records charge their original payload
length until fully acknowledged and keeps production paused while that charge
exceeds P, including when only ACK records remain. Both retain their independent
entry caps. UDP
has no such pending stream queue.
UdpOverTcpClient and UdpOverTcpServer carry UDP datagrams over a byte-stream carrier. Each datagram-facing Payload callback supplies exactly one datagram. The encoder prepends the two-byte length and hands off the whole owned buffer in one callback; input beyond 65,505 bytes is dropped intact. Source protocol flags never select a TCP payload mode. Mixed listeners route TCP separately.
Both nodes advertise splice support. Their fixed-header splice stream caches only the two-byte length, and each complete body is extracted into one owned buffer with onward padding. Datagram bodies remain eligible for private pipes; neither node sets an ordinary-read preference for UDP. UDP socket fallback belongs to the sender. Both initialize their framing state in Init and forward next Init immediately; the server sets the destination protocol to UDP first. There is no protocol-selection marker. Transport Est, Pause and Resume are forwarded promptly, without an encoder queue or pending control output.
Each receive stream drains complete admitted frames before checking its 4 MiB retained buffer charge, attempting beneficial compaction before refusal. The decoder accepts nonzero 16-bit wire lengths through 65,535 bytes. Its drain can stop only on an incomplete header or body, leaving at most 65,536 bytes including the two-byte header. Assert this derived bound instead of adding an unreachable overflow path. It does not bound allocation charge: a small suffix can retain a large backing buffer, so charge checks remain necessary. Parser reentry shares 2 MiB of retained logical bytes and 4 MiB of charge with the older stream. Complete frames in the synchronous dispatch continue after Pause; hard admission refusal, zero data length or Finish stops dispatch and closes a borrowed normal carrier through its owner. Neither node discards arbitrary stream bytes to resume parsing. Their fixed-header stream allocation follows the fatal heap-allocation policy.
Stream admission limits belong to the node and do not derive from the pool
large tier, splice read target or actual pipe capacity. HttpProxyServer keeps a
fixed 2 MiB of delivery headroom per direction alongside its configured working
budget. An active large delivery publishes its unconsumed buffer while its prefix
streams through working storage. Nested input appends behind that suffix and must
fit the same direction's delivery headroom together with any deferred remainder;
there is no separate reentry allowance. Raw fallback and CONNECT relay can hand
off retained ordinary buffers directly, preserving padding and FIFO order even
when the opposite direction occupies the working budget.
When first-request authentication fallback is eligible, only the original
complete header gets an extra immutable snapshot, bounded by max-header-bytes + 1.
Wipe it on protected commitment or refusal. Selected replay enters the existing
working/output budget before fallback Init; bodies and pipelined tails stay in
the existing input/deferred slots. Admission must account for the active Payload
suffix before any reentrant branch callback; snapshot storage is not another
backlog allowance. ObfuscatorClient/Server retain at most 2 MiB plus one maximum wire record
(65,540 bytes); their 65,535-byte record-body maximum remains separate. The
packet/stream adapters above keep 1 MiB of extra delivery headroom. These values
apply independently of RAM profile, platform support and misc.splice. Their
entry limits, allocation checks and overflow/close behavior remain in force.
A pool tier is not a universal maximum callback size: frame assembly and other transforms can produce larger ordinary buffers even in chains without splice. Keep protocol record limits, pending-data budgets and allocation geometry distinct. Multiple valid records delivered together are not an oversized record; process them when possible while preserving bounded admission, FIFO and the exact node's flow-control role. Changing a pool or pipe target must not silently change these node policies.
HalfDuplexServer and HttpServer split waiting caps use
bufferpoolGetWaitingBudgetBasis(), a separate immutable pool setting: 64 KiB in
S1/S2 and 1 MiB in higher profiles. They keep
131070 * max(1, ceil(basis / 32768)): 262,140 bytes in S1/S2 and 4,194,240 bytes
in higher profiles. Enlarging splice targets does not enlarge these
waiting caps. Device pools inherit both the splice limit and waiting-budget
basis from their worker pool; custom pools provide each explicitly.
ReverseServer also materializes complete splice deliveries in both directions while a half is unpaired. Handshake validation/removal, waiting FIFOs and cross-worker handshake replay use ordinary buffers. Handshake completion does not permit opaque retention while waiting; only pairing enables unchanged splice forwarding after startup FIFO replay. Materialization preserves onward padding; application retention follows the default 2 MiB / 1,024-buffer budget, independent of pool geometry and splice read sizes. PipeTunnel transfers paired opaque buffers across workers under its existing ownership and cancellation contract.
Payload length versus retained allocation charge
sbufGetLength() measures buffer contents. That is the correct unit for ordinary
queue byte counts, protocol thresholds, FIFO delivery statistics, and
backpressure decisions, and buffer_queue_t.total_len intentionally keeps those
logical payload semantics.
Do not treat that content length as a memory charge when a security or resource
limit specifically promises to bound allocations retained by a live queue. An
empty or one-byte sbuf_t can still retain its full capacity, left padding,
header, and allocator alignment overhead. Memory-sensitive code that needs such
a hard bound must keep a separate storage/resource charge. Mux's child, parent,
detached and outgoing queues use the queue-capacity policy above; it deliberately
does not bound physical kernel resources. It must not replace logical length in
BufferQueue or in the incoming-child FlowResume threshold.
Use bufferqueueGetCharge() for an individual BufferQueue instead of maintaining
a duplicate counter beside it. A whole-queue ownership transfer carries the budget binding and both cached
totals; initialize the relinquished source as empty and unbound. Reserved pop
keeps continued active retention charged. TCP adapters share one budget between
the queue and active WIO reservation, retaining original capacity charge until
completion or terminal callback detachment. Partial writes release logical bytes.
Mux's children, parser and detached associations retain their existing policies.
sbufGetAllocationCharge(const sbuf_t *buf) is the canonical O(1) charge for an
allocated buffer's own userspace storage. For ordinary buffers it returns
sizeof(sbuf_t) + sbufGetTotalCapacity(buf) + kSbufAllocationAlignment; capacity
already includes left padding. For splice wrappers it returns the header plus
original left padding, SPLICE_BUFFER_STORAGE_SIZE, and alignment overhead.
Splice capacity and length are logical, so they cannot measure wrapper storage.
The helper works before pipe initialization, makes no allocation or syscall, and
reads no payload bytes. Cursor movement, consumption, and prepending leave the
charge unchanged unless storage is replaced. It excludes kernel pipe memory,
allocator bookkeeping beyond explicit
alignment overhead, and pool/queue storage. The separate queue-charge helpers use sbuf capacity for both representations. This does not change sbufGetAllocationCharge() or
BufferQueue's logical-byte accounting.
bufferpoolQueryBestFit() predicts ordinary storage without allocating, consuming
a cache entry, or making a reservation. It shares tier selection with
bufferpoolGetBestFit() and reports aligned payload capacity, left padding,
allocation charge, and whether a configured pool tier fits. Unrepresentable
geometry returns false without changing the output. Predictions remain valid
while pool geometry is unchanged. Mux uses this query for ordinary compaction
and batch fallback reservations; compaction still requires a smaller pooled tier.
Linux RawSocket capture uses the same nonblocking worker FIFO admission as Linux TUN, with a shared 8 MiB allocation-charge and 512-packet budget. Each queue worker owns receive scratch and a bounded fragment-release batch outside that budget. A received record remains private until its Netlink verdict succeeds; only NF_DROP can lead to chain publication. On capacity refusal, keep the unadmitted output and disable reads until credit notification. Shared fragment storage uses detached allocations, never a different worker's buffer pool. Inactive startup/cleanup service uses coordinator-owned storage and NF_ACCEPT; it never dispatches into the session FIFO. Once capture activates, coordinator service during the remaining worker startup gate uses detached storage and NF_DROP, then admits packets through the same bounded FIFO. Capacity refusal retains the current output and stops that lane's reads. No tunnel callback runs on the coordinator; worker delivery begins after packet Init.
Windows, macOS and BSD TUN readers configure the session's byte/packet budget and
worker FIFO before producer startup: 8 MiB of buffer allocation charge and
1,024 packets. deviceReaderDistributeBatchDroppingOverflow() distributes complete
visible native TCP/UDP packets by flow and other complete packets using a
session-local sequence. It retains preserved-fragment association and groups
packets in stable worker order to fit both limits before nonblocking
admission. A capacity refusal drops and recycles the unadmitted remainder;
worker 0 and the external macOS/BSD readers never wait for another worker. An
individual allocation larger than the budget is dropped too. Accepted work retains its
reservation through delivery or cancellation, including closed generations.
Reader staging is bounded by one batch plus the existing fragment limits.
Windows/macOS batches contain at most 128 packets. BSD uses
min(RAM_PROFILE, kMaxReadDistributeQueueSize) with a 512-packet internal cap;
supported profiles allow at most 256 packets (server). Message metadata is
bounded separately by the packet limit. These
limits bound receive dispatch, not total process memory or downstream retention.
Linux TUN offload work reserves storage before transferring an aggregate, an assured complete ordinary TCP/UDP record, or ordinary checksum work to its selected receive-processing worker in either offload mode. Single-reader native TCP/UDP uses the same flow worker as ordinary input. The destination is retained across capacity retries and all segments of that record. Both modes prepare receive scratch, the capacity notifier and ordered worker queues before producer startup. Their session budget is 8 MiB of allocation charge and 512 packet slots; common setup must admit the largest retained ordinary work item. Each GSO work item charges its actual aggregate buffer allocation, its work context, and one maximum output-buffer allocation allowance; it reserves two packet slots for the retained aggregate and active output. The worker allocates and delivers one independent segment at a time, or delivers an assured aggregate intact on an eligible trusted pair. The output allowance stays reserved through every segment's required checksum work and TunDevice delivery, so the worker never waits for reader budget capacity. Downstream retention follows ordinary packet-chain ownership after delivery.
All non-segment ordinary work, including assured DATA_VALID and matching
NEEDS_CSUM records, reserves one packet slot, its compact
packet buffer's predicted allocation charge, and its private work context before
allocation and copying from reader scratch. It needs no segmentation output
allowance. Its reservation remains held through worker checksum completion and
delivery (omitting checksum completion when assured), or settles on refusal,
cancellation or stale generation. Scratch remains reader-owned and reusable for
the next read.
Before allocating each segment, the worker checks the current ordinary best-fit charge against its prepaid allowance. If custom ordinary pool geometry no longer fits that allowance, it creates an exact-sized ordinary buffer with this chain's original sufficient padding instead.
Ordinary batches use the same per-worker FIFO and reserve their actual buffer allocation charges and packet slots before posting. Reader drain staging and fragment storage retain their separate existing bounds; session and FIFO-message metadata are bounded separately by the packet limit. The 8 MiB admission charge is not a total process-memory limit. Budget exhaustion makes the reader wait, retaining at most one unadmitted record in its current scratch buffer. Successful GSO admission transfers that buffer itself and replaces reader scratch without copying the aggregate payload. Non-segment admission copies the exact IP bytes and reuses reader scratch. Release each work reservation exactly once after completion, refusal, cancellation, or stale-generation discard. Posting a work item is not settlement, and cancellation must not borrow a device or worker buffer pool.
Buffers transferred to scheduled line tasks
Calling lineScheduleTaskWithBuf() or
lineScheduleDelayedTaskWithBuf() transfers the buffer on every result,
including synchronous RejectedSettled. The caller must never recycle, destroy,
or inspect it after the call. If the task executes, the task callback receives
ownership. Otherwise the scheduler settles the buffer after optional
cancellation notification: it returns the buffer to the line's pool only while
cleanup runs in that owner event-worker context, including owner-bound teardown,
and destroys it on a foreign or unbound cleanup thread.
The cancellation callback does not receive or own the buffer. It runs before internal buffer release, while the scheduler's physical line reference remains held, and must not attempt to infer buffer lifetime from the cancellation reason. A null cancellation callback suppresses only notification; line death, submission refusal, quiescence, and teardown still release the buffer exactly once. This makes memory safety independent of task progress.
In debug builds, BUFFER_WONT_BE_REUSED(x) swaps a buffer for a fresh duplicate
and destroys the original, so any stale pointer to the old buffer is caught
quickly. If you see it in reference tunnels, it marks "this buffer must not be
touched again after this point."
A Complete Prepend Example
Putting it together — a framing tunnel that prefixes a 2-byte big-endian length on
the upstream path, having advertised .required_padding_left = 2 in node.c:
void myTunnelUpStreamPayload(tunnel_t *t, line_t *l, sbuf_t *buf)
{
const uint16_t payload_len = (uint16_t) sbufGetLength(buf);
// We advertised 2 bytes of left padding, so this is guaranteed to fit.
assert(sbufGetLeftCapacity(buf) >= sizeof(uint16_t));
sbufShiftLeft(buf, sizeof(uint16_t));
sbufWriteUI16(buf, payload_len); // (use the project's endianness helper as needed)
// Hand the buffer upstream; we no longer own it.
tunnelNextUpStreamPayload(t, l, buf);
}
On the downstream side the paired tunnel reads and strips the header with
sbufReadUI16 + sbufShiftRight, then forwards with
tunnelPrevDownStreamPayload.
Common Buffer Mistakes
- Prepending without advertising padding, then relying on left capacity that only exists in one chain layout.
- Misusing
sbufShiftLeftwhen left capacity is insufficient (the assert is your friend in debug; release builds corrupt memory). - Reusing or reading a buffer after passing it to a forwarding callback.
- Leaking a buffer on an error/close path.
- Using a different worker's pool than the line's own.
- Treating
sbufGetMaximumWriteableSizeas "spare room beyond the payload" — it is room from the cursor, so subtract the current length first.
HttpProxyClient framing and retention
Optional origin DNS precedes client Init. DomainResolver retains early ordinary, private-pipe and prefix-plus-pipe input under its own 2 MiB logical/2 MiB allocation charge FIFO bound, with no separate 1,024-entry cap. Client retention below starts after core Init; these are separate stages, not a shared entry cap or an aggregate 2 MiB connection-memory guarantee. Neither DNS nor helper insertion requires materializing body bytes or changing read preferences or splice capability.
Successful CONNECT acceptance releases response carry and the final header copy, clearing all borrowed parsed-header pointers before queued uploads or coalesced tunnel bytes can trigger callbacks. Pending queues, active buffer reservations and the idle/work timer remain live. HTTP mode retains its parsing storage for body framing and trailer validation until teardown.
HttpProxyClient advertises ten bytes of chunk-prefix padding. Its initial request uses a separate checked best-fit ordinary buffer with onward padding. A chunk's size prefix stays with its original body wrapper; an ordinary two-byte CRLF suffix is published before handing off the body and sent immediately afterward. Nested upload input waits behind that suffix. Pause/Resume relay transparently; the suffix and ready backlogs do not wait for Resume. Init, request/response readiness and reentrant dispatch still impose ordering barriers.
HttpProxyClient does not request ordinary reads by default: uploads and body-only response deliveries can preserve splice buffers. Preferences set by other nodes remain advisory; both ordinary and splice input are valid in every phase.
Ready CONNECT, fixed uploads, and response buffers wholly within an established
fixed/EOF/chunk-data range can transfer intact. Inputs containing HTTP metadata
or crossing a body boundary materialize completely into best-fit ordinary storage
through sbufEnsureOrdinary(), after admission enforces the delivery bound.
No splice wrapper enters the ordinary parser. This practical scope deliberately
allows copies for mixed inputs and small chunks.
A delivery is at most 2 MiB. Necessary pending input per direction shares a buffer_budget_t bound of 2 MiB logical bytes, 2 MiB queue-capacity charge and 1,024 entries. Active retained parsing input holds a reservation under the same budget; consumption releases bytes before callbacks. The opposite direction has its own budget so full upload storage cannot stop response progress. Header and framing carry have explicit fixed bounds; they are not general body queues. Materialization may transiently allocate one additional complete ordinary input of at most 2 MiB payload with onward padding, independently of pool tier sizes. This is not a physical-memory or kernel-pipe capacity guarantee.
Continue to Part 4: Packet Lines & Packet Tunnels.
lwIP engine and shared buffer storage
Production lwIP uses a fixed 2 MiB TCP send capacity and a fixed 4 MiB receive window per PCB. Receive-window scale 7 represents the full window when the peer negotiates scaling; peers without scaling retain the unscaled limit. These are protocol ceilings, not per-flow memory reservations. Payload storage comes from bounded shared pools as data is retained, so allocation pressure can limit a flow before its byte ceiling. Increasing these ceilings does not multiply the shared heap or segment pools by the flow count. The ordinary receive-pbuf pool reserves enough payload capacity for one full receive window process-wide.
PTC's default max-pending-bytes is also 2 MiB because it counts both queued
payload and original deliveries awaiting full acknowledgement. Its fixed 1 MiB
delivery headroom and separate 1,024-record limit still bound admission. CTP's
pending budget counts only payload not yet accepted by lwIP; its default remains
256 KiB independently of the stack's send capacity.
PTC and CTP admit stream payload into a scaled TCP send buffer through successive
16-bit tcp_write() calls. That API's 65,535-byte limit is not backpressure:
admission and FIFO retry continue while send capacity and allocation permit.
Each bridge keeps the uncopied suffix at the head of its write FIFO. PTC also
retains the original payload's ACK record charge until acknowledgement. A
retryable failure after a successful prefix still flushes that prefix so
acknowledgement can restore progress.
Worker engines share one bounded set of flow/payload pools, including PTC RX wrappers. Each engine caches at most 256 free objects per TCP-segment, pooled-heap size class and PTC RX-wrapper pool. A cache miss refills up to eight objects from the existing backing pool; it does not allocate another copy of that pool or increase its process-wide capacity. Warm checkout/return avoids the shared allocator mutex. The owner atomically detaches a free list before reading or changing it, then publishes the remaining list. Shared-pressure allocation can detach published lists from other engines, returning their free objects to the backing pool before retrying. A list briefly detached by an in-progress owner operation is published again before that operation returns. No pressure path traverses another engine's live PCBs or pbufs.
An entered engine may cache returned backing-pool storage regardless of which
worker originally allocated it. An unscoped or unregistered free uses the shared
allocator, without dereferencing a former owner's cache. Engine destruction
unregisters and drains its caches under shared protection before releasing the
engine; backing pools outlive all engines. Engine creation must not reset shared
allocation metadata. Pool locks cover allocator metadata only; custom destructors
and local protocol reclamation run after unlocking. Exhaustion still returns
NULL; an engine may reclaim its own eligible protocol objects before refusal.
Pool used/max statistics include objects reserved in local caches as well as
live objects; they describe storage checked out from the backing pool. Heap-byte
statistics continue to count live requested bytes. pbuf reference protection,
shared heap-byte statistics, ports, packet IDs and reassembly quotas keep their
existing synchronization. Allocator overflow/sanity diagnostics and memp-available
hooks use the uncached implementation so their per-operation checks remain intact.
The Unix SYS_ARCH_PROTECT guard uses the project's hybrid wmutex_t.
Exclusive shared bootstrap initializes it before allocator use, and shared
finalization destroys it after all engines and retained buffers have settled.
Thread-local nesting acquires the mutex only on the outermost entry and releases
it on the matching final exit. This protects shared metadata; it does not add a
TCP/IP core lock around worker-local protocol execution.
Each active engine separately owns 17 timeout records and its control tables. This control overhead does not multiply TCP, UDP, listener, segment, pbuf or heap capacities. IPv4 and IPv6 reassembly retain separate process-wide pbuf allowances. PTC and CTP detached drain lists are worker-local, but each node retains its aggregate 4,096-object and 16 MiB allowance with exact reservation rollback.
Engine-owned pbuf chains remain on their owner until release. Cross-worker messages carry detached bytes and lookup identity, not permission to access foreign pbufs, PCBs or worker pools. Retained custom pbufs must settle before owner buffer pools disappear. Foreign cancellation destroys detached storage; it does not recycle into another worker's local pool.
PTC stages accepted TCP receive bytes in one owner-held sbuf while its deferred
normal-line delivery is pending. The first receive can reuse a single, uniquely
referenced PTC custom pbuf's sbuf from the same owner worker and pool. Its accepted
payload range must lie inside that sbuf, both its original padding reservation
and remaining headroom must satisfy the onward pool requirement, and its payload
allocation must be no larger than the smallest fitting copy destination. An
oversized pool tier retains the bounded copy fallback. Other inputs are copied;
new allocations use the smallest fitting tier. Growth is geometric and staging
is bounded by TCP_WND, including a dedicated fallback when a custom pool tier
exceeds that bound. Growth briefly
owns both old and new storage. The task owns a line reference, not this buffer;
owner teardown releases staging after cancellation. Delivery detaches the sbuf
before calling the next tunnel, preserving reentrant input and Finish ordering.
One aggregated Payload retains the existing accepted-input Pause tolerance and
returns or withholds exactly its byte count as TCP receive credit. Refused task
submission releases copied staging and rolls back credit without consuming
lwIP's pbuf. A reusable input remains unchanged until the owner task is admitted;
then its sbuf transfers to staging and only the custom pbuf wrapper is freed.
Stripping packet headers does not replace the original padding reservation.
PTC receive copies use pbufLargeCopyToPtr(), which copies at most the first
packet's tot_len, skips empty spans, and stops before a queued next packet.
The caller supplies nonoverlapping destination storage for that length. Each
span below 64 bytes uses ordinary copying; larger spans use memoryCopyLarge().
Receive-buffer growth uses the same cutoff. Neither path assumes the TCP payload
or append position retains the original IP packet's alignment.
PTC netif output copies exactly one packet's tot_len from its pbuf spans into
one best-fit sbuf on the route owner worker, preserving chain padding. Empty
spans are legal; a following packet in a pbuf queue is excluded. Delivery transfers
that same sbuf. Deferred refusal/cancellation calls sbufDestroy() without tunnel,
line, engine or pool access; accepted owner delivery may recycle a stopped output.
No pbuf storage crosses the device callback boundary.
TUN output has one FIFO per event worker and uses that worker's existing buffer pool; there is no device writer pool. The device-wide packet allowance covers both queued buffers and the active flush batches across all workers. The queue limit is separate from the flush quantum: each deferred callback removes at most 64 packets and 256 KiB of allocation charge, leaving the next packet queued when it would exceed the quantum. A singleton exceeding that charge target still progresses ordinarily. No timer waits for more packets; remaining output schedules another deferred flush.
Linux trusted GSO records copy only a private IPv4/TCP header; iovecs borrow
original payload spans until the complete write attempt and EINTR retries finish.
No aggregate payload allocation/copy occurs. Every native batch callback settles
all of its buffers through its event worker's pool. Cancellation marks the queue
for discard; lifecycle queue drain destroys retained buffers without borrowing a
worker pool. A refused tundeviceWrite() leaves the buffer with its caller;
acceptance transfers it to writer settlement. Generation references keep late
flush-token cleanup safe after device destruction; see
Part 7.
Raw-device output also uses per-worker FIFOs and the existing worker buffer pools,
with device-wide allowances of 131,072 packets on Linux and 256 on Windows,
covering queued and active buffers.
A native callback consumes only the prefix it reports; the helper retains the
unsent suffix ahead of later FIFO entries, including across partial sends,
interruptions and writable retries. Linux keeps up to 512 original buffers in an
active batch. Each callback performs at most one nonblocking sendmmsg(); no
aggregate payload allocation is introduced. Invalid or oversized input at a
later position waits until the earlier valid prefix settles before it is dropped.
Windows overlapped output may borrow the first unsent packet while the kernel
operation is pending. Neither completion notification nor foreign cancellation
may recycle that buffer. The owner worker settles completed output through its
own pool. During stop, native cancellation/completion and notification quiescence
must finish before any retained packet is destroyed. Refused rawdeviceWrite()
keeps caller ownership; accepted output settles through the writer even when a
later retry is cancelled.