Part 5: Anatomy of a Tunnel & Workflow
This part maps the contracts from Parts 1-4 onto the actual files you edit. Once you know which file owns which responsibility, adding or fixing a tunnel becomes mechanical: make the smallest complete root-cause change in the right place and keep the lifecycle intact.
Directory Layout
Tunnel files use the following layout. Callback files and their directories exist only where the node implements behavior beyond its constructor defaults:
tunnels/MyTunnel/
CMakeLists.txt
description.md
include/MyTunnel/
interface.h # public WW_EXPORT API + custom callback prototypes
structure.h # tstate / lstate structs, size enums, helpers
instance/
create.c # tunnelCreate(), assign callbacks, parse + validate settings
node.c # node metadata: type, flags, layer group, padding, createHandle
chain.c # custom onChain hook, when needed
index.c # custom onIndex hook, when needed
prepair.c # onPrepare hook
start.c # onStart hook
stop.c # lifecycle-v2 quiesce, drain, and stop hooks
destroy.c # onDestroy hook: free state, then tunnelDestroy()
api.c # runtime API entry (tunnelApi)
common/
helpers.c # shared protocol helpers
flow.c # shared progress coordination, when needed
input.c # shared parsing/extraction, when needed
line_state.c # initialize + destroy per-line state
upstream/
init.c est.c payload.c pause.c resume.c fin.c
downstream/
init.c est.c payload.c pause.c resume.c fin.c
Two details matter in practice: every tunnel has an instance/api.c, and the
prepare hook file, when present, is spelled prepair.c (the function is
...OnPrepair). The
upstream/downstream finish files are fin.c, not finish.c.
Keep inherited callbacks at their defaults; do not add forwarding wrappers or
empty lifecycle files for them. Compare against the actual constructor: packet
payload defaults and disabled adapter directions intentionally abort.
tunnelCreate() also terminates on upstream Est and downstream Init by
default; nodes that support these events must explicitly install handlers.
packettunnelCreate() preserves lifecycle forwarding in both directions.
Other ordinary flow defaults forward. State changes, event absorption, cleanup,
and error handling require custom callbacks even when their bodies are short.
For an ordinary middle node, a custom Est handler must still promptly relay adapter establishment. Protocol negotiation, request emission and payload readiness belong in separate state, not in a delayed or replacement Est. Follow Part 2's Est contract when mapping distinct lines or adding a protocol-completion callback.
Choose flow state by role, not by copying another protocol's pump. Synchronous transforms use direct Payload and signal forwarding without redundant output queues. Ordinary middle nodes inherit transparent Pause/Resume forwarding, including repeated notifications; timers and deferred backlogs do not require a consumer-pause gate. Parsers keep incomplete input, and every backlog preserves FIFO and its own hard limits. Adapters, exact-line heads/tails, SpeedLimit and bandwidth-enabled UserController retain their flow-control responsibilities. Keep any reentry guard attached to the specific ordering obligation it protects; see Part 2.
Remove obsolete callback implementations when they have no registrations or runtime callers, together with their declarations and build-source references. Check public entrypoints and dynamic loading conventions before declaring a function unused; retain callbacks and APIs that remain reachable.
Keep one file per custom {direction}/{event}. Each callback may contain its
direction-specific dispatch, admission checks and direct forwarding so buffer
ownership and callback direction remain visible. Share parsing, framing, progress
coordination and teardown in cohesive common/ files, such as input.c, flow.c,
fallback.c or lifecycle.c, as their responsibilities warrant. Keep file-local
helpers static and expose only necessary cross-file helpers through the private
header. Callbacks should remain readable without hiding directional decisions
behind a boolean dispatcher; common code need not all live in helpers.c.
QuicClient and QuicServer keep their constructors, lifecycle hooks and
directional callbacks in this layout. Each node selects application or carrier
input explicitly and maps generated wire data, decoded application data and
application pressure in common/output.c. Internals/QuicCommon shares configuration
validation, framing and the protocol engine, with its C runtime split into
context/lifetime, input, flow and timer modules. The shared driver retains the
borrowed line and defers engine destruction across reentrant callbacks; node
callbacks keep Init and Finish forwarding visible in their own direction.
Most production presets enable unity builds, combining a target's C source files
into one translation unit. Give file-local functions, types and objects names
that remain distinct within that target; static does not isolate a name from
another included source file. Check the target in unity mode when adding or
extracting helpers, as ordinary separate-file and native-unit builds do not expose
these collisions.
What Each File Owns
| File | Responsibility |
|---|---|
instance/node.c | Returns the node_t describing this tunnel type: type, version, createHandle, flags, required_padding_left, layer_group, and neighbor layer constraints. |
instance/create.c | tunnelCreate(node, sizeof(tstate), sizeof(lstate)), assign only the flow callbacks and lifecycle hooks that differ from the constructor defaults, parse and validate JSON settings, allocate per-worker resources, clean up on any failure. |
instance/chain.c / index.c | The onChain / onIndex hooks. Most tunnels keep the framework defaults. Static internal topology known before solving belongs in onChain. A tunnel whose private topology depends on solved adjacent layers may also set optional onSolvedTopology from create.c. It receives the current solved snapshot, which is not necessarily final. The hook may be called repeatedly and returns true if and only if it changed topology; the snapshot then becomes stale and the manager solves again before invoking another hook. Once a complete pass reports no changes, onIndex runs once over immutable topology and may consume the final solved layers. |
instance/prepair.c / start.c / stop.c | onPrepare / onStart and the lifecycle-v2 shutdown hooks. Bootstrap in onStart; close admission in onQuiesceRequest; detach worker-affine work in onWorkerQuiesce; wait for external producers in onQuiesceWait; drain owned lines in onWorkerStop; release remaining shared resources in onStop. |
instance/destroy.c | onDestroy: free settings, SSL contexts, pools, thread-local arrays, then call tunnelDestroy(t). |
instance/api.c | The tunnelApi runtime entry. It receives an sbuf_t message, must recycle it, and returns an api_result_t. |
common/line_state.c | LinestateInitialize (called from Init) and LinestateDestroy (zeroes the aligned line-state region). |
common/*.c | Shared algorithms and coordination grouped by responsibility: protocol helpers, parsing, framing, progress, fallback or lifecycle. |
upstream/*.c, downstream/*.c | One custom flow callback each. Omit files for inherited callbacks. Keep directional dispatch, admission and direct forwarding here; delegate shared algorithms and coordination to cohesive common files. |
include/MyTunnel/structure.h | mytunnel_tstate_t, mytunnel_lstate_t, size enums, and small inline helpers. |
include/MyTunnel/interface.h | WW_EXPORT create/api and custom destroy prototypes, plus prototypes for implemented callbacks. |
wwapi.h provides all ww headers needed by nodes; use it instead of individual
ww headers, directly or through the node's own interface/structure headers.
Node implementations still include their intended logger-selection header explicitly.
Do not include logger-selection headers in ww library .h files:
that would select the logger for consuming nodes and override their intended
selection.
Caller preconditions and failure handling
Callers establish an internal helper's preconditions. A required tunnel, line,
buffer or output pointer is not an optional input merely because C can represent
NULL. Do not turn a programmer error into an ordinary false, NULL, success or
silent return, or duplicate argument validation at every layer.
Choose the check according to what can actually cause the condition:
| Condition | Handling |
|---|---|
| Caller-owned precondition or programmer-only invariant | Use assert() to document and check it in Debug, or remove a redundant check when the surrounding contract already establishes it. |
| Critical internal invariant requiring release-build protection, such as corrupted resource ownership | Keep a runtime check, emit LOGF, then call abortProgramNow(). Do not disguise corruption as a recoverable protocol failure or request orderly shutdown and continue. |
| WaterWall memory-manager heap allocation failure | The memory manager calls abortProgramNow(1) without normal logging or orderly cleanup; see heap allocation failure. |
| Expected failure, external input or supported state | Keep ordinary runtime validation and the documented failure/cleanup path. Examples include unrepresentable sizes, I/O refusal, malformed peer data, exhausted budgets or fixed resource inventories, incomplete frames and optional NULL arguments. |
Trace callers before classifying a condition. A NULL callback context after detachment, a stale association, a canceled timer, or state released during a reentrant callback can be expected. Keep those guards and exact-line lifetime checks. A reference preserves the allocation, not the logical life of the line. An entry assertion never replaces a required check after calling another tunnel. Required internal arguments normally belong in the first row; the possibility that caller misuse could crash does not by itself justify another runtime guard.
Split mixed checks instead of deleting the whole expression. For example, a TLS
output helper can assert that its tunnel, line, state and write BIO are non-null,
while still returning failure if TLS output fails or a callback releases its
state. Document nullable parameters explicitly. Keep operations with side effects
outside assert() so Release builds perform them too; assertions disappear under
NDEBUG.
Avoiding redundant checks and state
A check is redundant only when an established precondition, earlier validation or API postcondition guarantees it on every path reaching that check, and intervening operations cannot invalidate that guarantee. Trace error paths, reentry and teardown as well as ordinary progress. A branch being rare or absent from tests does not make it impossible.
- Use a helper's actual guarantees. For example,
addresscontextIsDomain()already establishes a non-null domain and nonzero stored length; it does not validate protocol-specific hostname syntax or agreement with the C-string length. Keep checks for properties the helper does not establish. - Preserve a shared checked API's documented invalid-input, incomplete-input and capacity-refusal outcomes even when one caller prevalidates its input. A local cleanup may narrow an internal helper's contract only when its complete caller set establishes the preconditions. Document a non-obvious size bound or ordering guarantee where future callers need it.
- Store independent facts rather than flags that merely duplicate a phase or
another flag. Check every assignment and transition for the exact line role
before removing state; similar client and server implementations need not have
identical reachable states. Whether a neighbor received
Init, for example, remains a separate fact when local setup can fail before dispatch.
When a redundant branch is removed, also remove private fields or helper results that lose their last purpose. Use the assertion policy above for programmer-only invariants; retain real input, admission and callback-lifetime failures. For allocation postconditions and incoming-buffer checks, see Part 3.
Heap allocation failure
WaterWall's memory manager does not support recovery from heap-allocation failure.
A valid heap allocation returns usable storage or terminates the process at that
boundary with abortProgramNow(1). This applies to initial allocation, growth and
duplication, including startup and optional facilities.
Do not add nullable OOM APIs, caller-side OOM checks, retries, fallback modes or
per-line cleanup solely to recover from heap exhaustion.
Route runtime-owned heap storage through memoryAllocate*(), memoryCalloc(),
memoryReAllocate() and the matching memoryFree*() APIs. The normal backend is
mimalloc; do not bypass this boundary with CRT allocation to obtain a recoverable
OOM result. This includes lwIP engine control storage, worker-engine slots and
caller-owned Windows adapter-query buffers. Storage supplied by an OS or
third-party API still uses that API's matching release function. WaterWall-owned
TLS ex-data, session-cache records and custom BIO state use WaterWall allocation
and matching frees in their destructor callbacks; attaching them to an SSL/BIO
object does not transfer their allocation policy to the TLS library. Keep failure
handling for the actual SSL/BIO objects and ex-data registration operations.
Runtime executables replace all 20 global C++17 allocation/deallocation overloads:
scalar and array new/delete, including nothrow, sized-delete and aligned forms.
Ordinary storage uses memoryAllocate()/memoryFree(); aligned storage uses
memoryAllocateAligned()/memoryFreeAligned(). This covers QUICHE and linked
C++ containers and preserves the selected mimalloc or ALLOCATOR_BYPASS backend.
Heap exhaustion remains fatal even for nothrow allocation. Aligned geometry
refusal remains distinct: throwing forms raise std::bad_alloc, while nothrow
forms return nullptr. Constructor-failure cleanup uses the matching allocator.
Direct C allocation calls and class-specific allocators retain their own
contracts; global replacement does not intercept them. The standalone launcher
and build-time host tools do not acquire the runtime override.
TcpOverUdp's shared FEC implementation retains its private C++ allocator for
objects, nested vectors and shared-pointer control blocks; it also routes that
storage through WaterWall allocation.
Its C-facing boundary terminates immediately on std::bad_alloc, while invalid
geometry, malformed shards and refused output remain ordinary failures. Preserve
that distinction when changing its exception handling.
Tunnel-local helpers must not expose failure results or rollback solely for
WaterWall heap exhaustion. Fixed, representable buffer-queue initialization and
credential copying are infallible. Helpers that also validate geometry, admit bounded work or
create native resources remain fallible for those reasons. Tests must not simulate
recoverable OOM by returning NULL above the allocator gate; exercise actual
admission/resource refusal there and test fatal heap failure below the gate.
Runtime cJSON uses the same fatal allocator. Creating an object or array and adding fresh items with valid keys and values needs no OOM rollback. Keep parse, serialization-size and invalid-argument failures; a nullable JSON result is not necessarily an allocation failure. Likewise, inserting an absent key into an already-reserved map needs no allocation recovery when admission proves it fits. Keep duplicate-key and capacity refusals wherever callers can reach them.
The standalone launcher does not link the runtime allocator. Shared configuration
validation follows the existing WW_CJSON_CRT_ALLOCATOR build selection: runtime
builds use WaterWall allocation, while standalone launcher builds retain CRT
allocation and their recoverable failure result. Do not link the runtime into
the launcher solely to share its allocator.
Shared integer JSON readers use cJSON's exact signed-integer provenance. Parsing
accepts plain integer tokens through INT64_MAX without a double round trip; decimal
and exponent spellings remain usable floats but are rejected by integer readers,
including nonzero fractional underflow. Unsigned decimal strings extend through
UINT64_MAX. Use cJSON_CreateInt64() / cJSON_SetInt64Value() or
jsonAddUint64ToObject() for exact integer creation. Number setters validate their
supplied double, duplication preserves validity, and direct field writes cannot
establish it. Parser and standalone builds share this representation.
For fields that require exact C strings, use
jsonObjectFieldStringsAreExact(object, key) before consuming them. It checks
every matching member, including duplicates, for embedded NULs in its name or
string value and for invalid UTF-8. cJSON retains decoded-NUL evidence through
duplication and references; replacing a name or value through its setter clears
only that string's evidence. This check is opt-in: presence, type, ranges and
recognized setting names remain the consumer's responsibility. QUIC applies it
in Internals/QuicCommon/config.c; the generic config loader preserves malformed
raw variable values during substitution without knowing QUIC's setting names.
Serialization does not preserve decoded-NUL evidence, so validate before printing
and reparsing a tree.
Zero-sized allocation/calloc requests return at least one freeable byte.
memoryReAllocate(ptr, 0) instead frees ptr and returns NULL. Invalid aligned
allocation geometry and overflowing memoryCalloc() multiplication return NULL
before the backend allocator is called. OPENSSL_memory_alloc() likewise returns
NULL before allocation when adding its bookkeeping header would overflow.
Preserve checks for these documented results when the caller can supply such
requests.
The OOM path must not use the normal logger, allocate more memory, acquire
application locks or call cleanup callbacks. It does not request orderly
shutdown: requestProgramShutdown() returns to its caller, while an allocation
caller cannot continue without usable storage. abortProgramNow() immediately
terminates through the independent fatal path and deliberately skips orderly
cleanup; see Part 7.
Validate external sizes, integer arithmetic and configured admission limits before allocating. Keep documented refusal for unrepresentable geometry, exhausted byte/entry budgets, empty fixed inventories, OS operations and third-party or standalone allocator interfaces that retain their own contracts. Keep optional-null and explicitly free-only operation results. Those results do not imply that a WaterWall heap allocator can return after OOM. Split checks with mixed causes and preserve ownership on every supported refusal; removing allocation recovery never authorizes removing bounds or lifetime checks.
Typed internal capabilities versus runtime messages
Use include/<Tunnel>/interface.h for a typed capability exchanged directly
between compatible tunnels already known to share a chain. For example,
UdpListener exposes a dynamic UDP endpoint provider as a value-handle API:
the caller opens, activates, queries, and closes an endpoint on its validated
owner worker. The capability must define ownership, worker affinity, stale-handle
behavior, and callback routing; it must not expose a usable foreign line_t *.
Do not turn such a local capability into instance/api.c. That file is the
external runtime message entry: it accepts an sbuf_t, recycles it, and returns
an api_result_t. It has different admission, serialization, and lifecycle
requirements. A typed internal capability remains ordinary local C calls and
must still preserve the normal tunnel callback chain.
Address-context metadata ownership
address_context_t.optional_flags.detected_protocols carries two logical
classes of optional metadata. Router/sniffer-owned bits record observed
application protocols; route-control bits carry a one-shot internal instruction
such as an already-negotiated SOCKS UDP relay endpoint. A Router starting a new
sniffing window must call addresscontextClearDetectedProtocols(), which clears
only observed protocol bits. addresscontextClearOptionalFlags() is the full
reset for a caller that genuinely replaces the context. The designated outbound
adapter consumes route-control metadata exactly once and clears it after setup;
rules must never treat route-control bits as sniffed protocols.
Framework-owned worker messages, timers, WIO callbacks, and async DNS requests
settle before component destruction under lifecycle-v2, so those contexts may
carry tunnel_t * directly when they also validate and use their explicit owner
worker. This is not a general lifetime rule: a callback root outside the worker
lifecycle, such as a device reader thread, must close local admission and wait for
active operations through an owner-specific mechanism such as
quiescence_gate_t before the tunnel or device state is reclaimed.
node.c: Declaring the Node
node.c is where a tunnel announces itself to the runtime. Here is
TcpListener's, which is a good template for an adapter:
node_t nodeTcpListenerGet(void)
{
const char *type_name = "TcpListener";
node_t node = {
.type = stringDuplicate(type_name),
.hash_type = calcHashBytes(type_name, stringLength(type_name)),
.version = 0001,
.createHandle = tcplistenerTunnelCreate,
.flags = kNodeFlagChainHead,
.required_padding_left = 0,
.layer_group = kNodeLayer4,
.layer_group_next_node = kNodeLayerAnything,
.layer_group_prev_node = kNodeLayerAnything,
.can_have_next = true,
.can_have_prev = true,
};
return node;
}
The fields you will set most often (all defined in ww/objects/node.h):
| Field | Meaning |
|---|---|
type / hash_type | The string used in JSON "type", plus its precomputed hash. |
createHandle | The TunnelCreateHandle that builds the instance. |
flags | One or more node_flags: kNodeFlagChainHead, kNodeFlagChainEnd, kNodeFlagNoChain, kNodeFlagSingleton, kNodeFlagSupportsSplice, kNodeFlagBlocksSplice. |
required_padding_left | Left-padding budget this tunnel may prepend into (see Part 3). |
layer_group | The layer category this node operates on: kNodeLayer3 (packet), kNodeLayer4 (stream), kNodeLayerAnything (supports both), or kNodeLayerNone. (Note: kNodeLayerSameAsNext, kNodeLayerSameAsPrev, kNodeLayerOppositeNext, and kNodeLayerOppositePrev are forbidden for layer_group and only allowed in layer_group_next_node / layer_group_prev_node). |
layer_group_next_node / layer_group_prev_node | Required layer group for next/previous neighbor (kNodeLayer3, kNodeLayer4, kNodeLayerAnything, kNodeLayerSameAsNext, kNodeLayerSameAsPrev, kNodeLayerOppositeNext, kNodeLayerOppositePrev, or kNodeLayerNone). Note: Opposite flags must be combined with a base layer group (e.g. `kNodeLayerAnything |
can_have_next / can_have_prev | Whether a neighbor is permitted on each side (true/false). |
Layer Groups & Strict Topology Validation
During chain startup, NodeManager strictly enforces node capabilities and layer rules via validateTunnelChains and the bounded monotonic layer constraint solver (nodeLayerSolveChain):
- Capability constraints: If a node specifies
.can_have_next = false(or.can_have_prev = false), having an adjacent neighbor on that side results in immediate startup failure. Unchained nodes marked withkNodeFlagNoChainmust not have adjacent neighbors. - Metadata shape validation:
layer_groupmust be concrete (kNodeLayerNone,kNodeLayer3,kNodeLayer4,kNodeLayerAnything). Forward relative flags (kNodeLayerSameAsNext,kNodeLayerOppositeNext) are allowed only inlayer_group_prev_node, and backward relative flags (kNodeLayerSameAsPrev,kNodeLayerOppositePrev) are allowed only inlayer_group_next_node. Opposite flags must always include a base layer group (e.g.kNodeLayerAnything | kNodeLayerOppositePrev). - Edge-domain constraint propagation: The runtime constructs a directed edge graph representing callback connections between adjacent tunnels, seeds each edge domain (
{L3},{L4}, or{L3, L4}) from participating node and neighbor layer requirements, and propagatesSameAsandOppositerelations monotonically to a fixed point. Any edge domain converging to{}immediately fails startup with a clear diagnostic identifying both nodes and sides. - Conditional SameAs vs Strict Opposite:
SameAsrelative constraints (kNodeLayerSameAsPrev,kNodeLayerSameAsNext) are conditional: when the referenced side is absent, no equality relation is added, and the node's placement flags (kNodeFlagChainHead,kNodeFlagChainEnd,kNodeFlagNoChain) determine whether boundary placement is permitted. In contrast,Oppositeconstraints (kNodeLayerOppositePrev,kNodeLayerOppositeNext) are strict and always require both participating sides to be present. - State-held cross-tunnel relations: For nodes whose callback targets are established dynamically at runtime (such as paired
Bridgetunnels), tunnels register logical layer relations directly on the chain viatunnelchainRegisterLayerRelation(). Registered relations couple the participating edge domains (e.g. equating Bridge A prev edge with Bridge B prev edge, and Bridge A next edge with Bridge B next edge) during solver execution and persist across chain combinations (tunnelchainCombine()). - Fixed and relative bridges: Fixed-side bridges explicitly declare concrete
layer requirements on their adjacent edges rather than claiming
kNodeLayerAnything.ConnectionToPacketsis L4 onprevand L3 onnext;PacketsToConnectionis L3 onprevand L4 onnext;StreamToPacketsis L4 onprevand L3 onnext. Dynamic bidirectional bridges such asWireGuardDevicedeclareOppositerelations that couple opposite edge domains without prematurely collapsing unresolved intermediate subchains. - Packet chain classification & bootstrap: A chain allocates worker packet lines and sets
contains_packet_node = trueonly when at least one edge is forced to{L3}, a node or edge requirement is fixed toL3, or anOppositerelation exists. Persistent packet lineInitmessages are queued at runtime only when the chain head's next edge is resolved to{L3}. - Failure semantics: Any validation or solver failure logs a fatal diagnostic (
LOGF) and records a startup failure (startupFailureRecord(1)), causing WaterWall to reject the configuration and perform a clean startup rollback.
Bridge and reverse-node adjacency
The reverse topology requires both direct adjacencies:
ReverseServer -> Bridge: Bridge must be ReverseServer's immediate next node.Bridge -> ReverseClient: Bridge must be ReverseClient's immediate previous node.
No node may intervene in either pair, including a transparent transform,
logger, or limiter. Both Bridge -> SpeedLimit -> ReverseClient and
ReverseServer -> SpeedLimit -> Bridge are forbidden. ReverseClient creates a
line toward its previous side using downstream Init, and Bridge translates
that callback into upstream Init on its paired branch. These are chain-design
requirements that users must follow. The code does not automatically enforce
either adjacency rule. The direct connection keeps the exceptional direction
change at the reverse/Bridge boundary; ordinary stream nodes outside it retain
their usual left-owner topology assumptions. See
Part 2 for the scope of
those assumptions and the corresponding Finish order.
Node Flags
UdpStatelessSocket is a chain-head adapter by design. It requires next,
forbids prev, and creates normal peer lines using upstream Init only.
Outgoing datagrams and callbacks from the chain arrive downstream from next.
Tail placement and downstream peer initialization are intentionally disabled;
this is the supported topology contract, not a temporary workaround. WireGuard
layouts using this socket place its transport side toward prev.
No runtime role flag is needed; upstream callbacks retain the adapter's disabled
defaults. The idle handle distinguishes owned peer lines from borrowed transport
or packet lines, whose downstream lifecycle callbacks have no peer state to manage.
| Flag | Meaning |
|---|---|
kNodeFlagNone | No node capabilities selected; use alone. |
kNodeFlagChainHead | May start a chain (an ingress adapter). |
kNodeFlagChainEnd | May end a chain (an egress adapter). |
kNodeFlagNoChain | Works without being in a chain (e.g. a database/user-auth node). |
kNodeFlagSingleton | Only one instance allowed in the whole config. |
kNodeFlagSupportsSplice | Advertises support for splice payloads. |
kNodeFlagBlocksSplice | Explicitly disables splice for the entire chain; takes precedence over kNodeFlagSupportsSplice. |
Use kNodeFlagNone only when no other node flags apply. When capabilities are
selected, combine those flags without adding kNodeFlagNone.
tunnel_chain_t.supports_splice is false during assembly. At
tunnelchainFinalize(), it becomes true only when WW_HAVE_SPLICE == 1,
misc.splice is not disabled, the solved chain has no packet nodes
(contains_packet_node == false), and the chain is nonempty with every
tunnel's node advertising kNodeFlagSupportsSplice and none advertising
kNodeFlagBlocksSplice. Packet chains and builds without splice support always
leave the chain flag false, regardless of node
flags. The node check includes internal helpers and tunnels brought in by
chain merges. Finalization follows the
complete onChain phase and the solve/onSolvedTopology expansion loop;
onChain alone is too early because topology-dependent helpers can still join.
kNodeFlagSupportsSplice is a two-direction safety promise, not a per-callback converter:
every reachable payload, retention, error, and close path must accept splice
input safely, and the node must also accept ordinary fallback input. Advertising
the flag does not promise that every path remains zero-copy.
kNodeFlagBlocksSplice records an explicit node-level exclusion. It wins even if
both splice flags are set on a node. A node with neither flag still prevents
splice because support must be explicitly advertised. The blocker applies to the
final expanded/merged topology, including internal helpers, and does not depend
on which runtime mode or branch a particular connection uses.
TlsClient, TlsServer, EncryptionClient, EncryptionServer, RealityClient,
RealityServer, ObfuscatorClient, ObfuscatorServer, Disturber,
TcpOverUdpClient, TcpOverUdpServer, HttpClient, and HttpServer explicitly
block splice. Internal node copies must preserve these capability flags,
including RealityClient's embedded TlsClient.
The chain flag applies build/startup/topology restrictions. Final eligibility
requires WW_HAVE_SPLICE == 1, misc.splice not disabled, no packet node, a
nonempty finalized chain, support on every tunnel and no explicit blocker after
helper/topology expansion. Eligibility alone does not activate splice I/O; the adapters still
select it. WW_HAVE_SPLICE reports Linux splice API availability independently
of the event watcher.
TcpListener, TcpConnector, DomainResolver, MuxClient, MuxServer,
Bgp4Client, Bgp4Server, HttpProxyServer, VlessClient, VlessServer, TrojanClient, TrojanServer, HeaderClient, Bridge, UserController, BlackHole, ReverseClient, and
JunkDatagramSender, UdpListener, UdpConnector, UdpStatelessSocket,
TcpUdpListener, TcpUdpConnector, Router, SniffRouter, ReverseServer,
Socks5Client, Socks5Server, HeaderServer, KeepAliveClient,
KeepAliveServer, SoftIpLimiter, SpeedLimit, UdpOverTcpClient, and
UdpOverTcpServer, ConnectionFisherClient, and ConnectionFisherServer advertise the flag. UdpOverTcp preserves exact framed bodies
as UDP datagrams over a TCP carrier and leaves UDP socket fallback to the sender.
SoftIpLimiter materializes only during initial identity inspection; SpeedLimit
preserves private pipes in its bounded queues and splits exact token grants with
ordinary fallback. HeaderServer materializes only while reading
its initial header; KeepAlive uses the fixed-header splice stream for ongoing
framing. See Part 3 for their ownership, reentry and fallback contracts.
This includes the internal DomainResolver inserted by
TcpConnector or Router's resolve-domains setting. Router and SniffRouter
materialize complete deliveries while route selection is undecided or nested
input waits behind startup replay, then forward opaque buffers in both directions.
Eligible next-path routes permanently retire after replay; alternate branches
retain routing state. See route retirement.
Every configured route branch and
helper remains part of the whole-chain capability check, so an unselected
blocking branch also disables splice. See Part 3 for their ordinary pending
storage and exact replay contract. Mux retains valid queued bodies under local queue-capacity charges. Incoming
assembly shares the parent receive limit with attached child queues; beneficial
pressure compaction and actual pipe-transfer fallback remain available. Its fixed-header stream and atomic large
splice batches preserve both callback directions. This does not promise zero-copy
for every frame. BufferStream and HTTP parsers require ordinary memory. HttpClient and HttpServer
explicitly block splice. HttpProxyServer materializes at admission except
for ready, unpaused CONNECT/fallback whole-buffer relay; its session slots remain
ordinary in both directions (see Part 3).
ConnectionFisher accepts splice-backed startup input through whole-delivery materialization before ordinary parsing, then preserves opaque bodies in application FIFOs and ready forwarding. Its fixed probe marker is separate from the following application-data budget. Handshake streams and empty setup queues retire after setup/drain; client main/child mapping and Finish ownership remain active. The server permanently bypasses after next Init and replay settle, with its full line state already destroyed. See Parts 2 and 3.
HalfDuplexClient and HalfDuplexServer also advertise splice support. The client requires 17 bytes of left padding; the server requires zero. Client first-intro construction reuses ordinary buffers when headroom permits and materializes splice input. Server parsing/waiting storage materializes complete deliveries; their direct paths preserve wrappers. The server's PipeTunnel shares the child's node metadata and participates in the expanded-chain capability check. This capability does not promise zero-copy setup, change pairing or waiting limits, or add read preferences. See Part 3.
Socks5Client and Socks5Server keep negotiation input ordinary, including nested parser admission, and preserve opaque TCP bodies after setup. Their UDP paths fully materialize datagrams and request ordinary reads on each associated line. Client local DNS and server authenticated UserController helpers retain their splice flags; AuthenticationClient lookup is outside the payload chain. See Part 3 for the exact read-preference timing and unchanged protocol/backlog bounds.
TrojanClient supports opaque TCP and staged variable-header UDP decoding, including its dependent owned carrier and destination-context selection. Its internal DomainResolver remains eligible. TlsClient still blocks a chain containing it; TrojanServer supports staged request authentication, opaque CONNECT, UDP backends and exact fallback replay. Eligibility includes its UserController and all reachable fallback/connector helpers; the AuthenticationClient lookup dependency is not a payload hop. TlsServer remains a blocker. See the Trojan buffer contracts in Part 3.
VlessClient supports both callback directions for TCP, UDP and destination-context selection, including retained input and dependent carrier cleanup. Its internal DomainResolver remains eligible. The response/addon barrier and UDP length parser use node-local bounded metadata storage; ordinary fallback remains valid. VlessServer supports staged request authentication, opaque TCP, fixed-destination UDP and exact fallback replay. Its expanded chain includes UserController and fallback helpers; AuthenticationClient is a lookup dependency. TlsClient and TlsServer remain chain-wide blockers. See Part 3 for exact retention limits and representation handling.
VmessClient and VmessServer advertise kNodeFlagBlocksSplice with zero required left
padding. Their shared codec lives in Internals/VmessCommon; neither endpoint depends
on the other. Client construction prepares one immutable UUID-derived key, validates
exact setting types and unambiguous aliases, and shares command/framing validation
with the codec. It is an ordinary L4 middle node supporting prev and next. Its owned
DomainResolver is inserted before it when local resolution is requested; transport
composition remains configured through the existing TLS/HTTP nodes. Client-only
construction has no AuthenticationClient, UserController, replay table, credential
vector or authentication pump. Persistent codecs remain active even for NONE/zero;
see Parts 2 and 3 for bootstrap, ownership and ordinary-buffer bounds.
VMess credential normalization is private to VmessCommon. Preserve strict dashed or compact UUID parsing; exact nonempty UTF-8 IDs of at most 30 bytes use UUIDv5 with the nil namespace and provider SHA-1. Do not trim, case-fold arbitrary IDs, normalize Unicode or accept malformed UUID-sized strings as short IDs. Local server duplicate admission compares normalized UUID bytes. Managed AuthenticationClient passwords remain canonical UUIDs; do not scan raw passwords or alter shared UUID indexes.
Client security=auto resolves once at construction using proven runtime CPU/OS
AES/carryless-multiply capabilities and direct-provider cipher availability, with a
conservative ChaCha preference when acceleration is unproved. Explicit unavailable
ciphers fail clearly; never infer TLS or automatically select NONE/zero. Server
allowed-security is a nonempty unique strict list of actual body modes; reject an
allow-list with no usable mode for its enabled commands. New options retain exact
JSON type/encoding/duplicate validation and introduce no negotiation bits.
Both endpoints register the read-only stats operation at construction and unregister
after admitted callbacks settle, before target state is reclaimed. The historical
TunnelApi callback alone does not expose it. Accept only a bounded empty JSON object;
return fixed-schema effective configuration and aggregate counters, preserving uint64
precision as decimal strings. Load synchronized published counters; never traverse
live lines or owner-only destination tables on the API caller's worker. Server auth/
cache/replay occupancy uses bounded nonblocking snapshots and returns busy on lock
contention. Respect deadline, working-memory and actual result-pool charges. Snapshots
are approximate during traffic. Application counters exclude packetaddr envelopes;
wire counters include protocol framing. Add no reset/mutation operation or stats scheduler.
Verbose diagnostics use bounded per-node sampling and fixed reason categories, without
credentials, keys, plaintext or per-user/per-destination metric labels.
BGP supports both representations through in-place framing, bounded frame FIFOs and a 19-byte fixed-header receive stream. OPEN inspection reads only its bounded metadata; private-pipe creation or transfer refusal selects ordinary fallback. The fixed per-direction retention policy is independent of pool sizing.
HeaderClient prepends its one-time header only in real left padding, preserving
existing prefixes and the private-pipe body. Bridge transfers the original buffer
and line through its paired callback mapping; eligibility includes the merged
chain. UserController preserves opaque payloads and accounts logical bytes, including
prefixes, preserving reverse-direction accounting. Its shared shaping machinery also serves SpeedLimit.
Finite user rates retain bounded FIFOs, split streams through exact range moves and preserve datagrams whole.
Internal creators bind their local payload-mode resolver immediately after construction; manual controllers
require an explicit payload-mode. See Parts 2 and 3 for classification, delivery commits and resource bounds. Rejected traffic and BlackHole
input recycle through the line's pool, discarding unread pipe bytes; healthy empty
pipes may remain cached. BlackHole's active Init still closes the line and does
not admit payload afterward.
ReverseClient forwards application buffers between its owned normal lines and recycles held input if activation closes the pair. Its handshake and JunkDatagramSender's generated junk/delayed duplicates stay ordinary; forwarded application bodies can stay in private pipes. Existing callback directions, ordering, lifecycle, and direct Bridge -> ReverseClient adjacency remain required. ReverseServer materializes complete deliveries in both directions until pairing is complete; handshake inspection and waiting/replay storage remain ordinary. After pairing it forwards opaque application buffers, including through its PipeTunnel wrapper, which shares the child's capability flags. Waiting limits and direct ReverseServer -> Bridge adjacency remain required. HeaderClient support does not imply HeaderServer support. Ordinary fallback stays valid; packet configurations of dual-layer nodes still disable splice globally. These capabilities make no performance promise without measurement.
UDP capability covers sending existing private-pipe bodies; UDP receives stay ordinary. Combined TCP/UDP wrappers retain both children's flags and include both branches plus inserted helpers in final eligibility, even for a TCP line. An eligible TCP branch can therefore use its existing splice I/O without changing protocol selection. Datagram failure and full fallback follow Part 3; packet configurations remain excluded globally.
External-node ABI
External libraries must export waterwallNodeLifecycleAbiVersion() returning
WW_EXTERNAL_NODE_ABI_VERSION, currently 7. The loader rejects missing or
mismatched descriptors before resolving or invoking a node getter. Rebuild
external libraries against the current headers: packet checksum requests use
sbufSetRecalculateChecksum() / sbufGetRecalculateChecksum(), and line_t no
longer contains that state or its old inline accessors. socket_filter_option_t is
passed by value and no longer includes shared_balance_table. Balancing state
belongs to private SocketManager filters, not the public options structure.
WW_EXTERNAL_NODE_LIFECYCLE_ABI_VERSION remains an alias for this complete gate;
the lifecycle callback semantics themselves are unchanged.
create.c: Building the Instance
create.c is the only place a tunnel is allocated. The pattern, distilled from
TlsClient:
tunnel_t *t = tunnelCreate(node, sizeof(tstate), sizeof(lstate));- Check
timmediately and returnNULLif creation failed. - Assign only callbacks whose behavior differs from the chosen constructor defaults.
- Read settings from
node->node_settings_json, validating each one. - Allocate per-worker resources (e.g. one SSL context per worker).
- On any later failure, clean up and bail out — destroy partial state and the
tunnel, return
NULL.
tunnelCreate(), adapterCreate(), and packettunnelCreate() are nullable by
contract. They return NULL on invalid or overflowing state sizes. Heap
allocation follows the fatal policy. Never assign a
callback or call tunnelGetState() until the result has been checked.
The cleanup discipline is real and worth following. The distilled shape is:
tunnel_t *tlsclientTunnelCreate(node_t *node)
{
tunnel_t *t = tunnelCreate(node, sizeof(tlsclient_tstate_t), sizeof(tlsclient_lstate_t));
if (! t)
{
return NULL;
}
configureTunnelCallbacks(t);
tlsclient_tstate_t *ts = tunnelGetState(t);
const cJSON *settings = node->node_settings_json;
if (! getandvalidateSniSetting(ts, settings))
{
goto fail;
}
// ... more validated settings ...
if (! createSslContextPool(&ts->threadlocal_ssl_contexts, /* ... */))
{
goto fail;
}
return t;
fail:
tlsclientTunnelstateDestroy(ts);
tunnelDestroy(t);
return NULL;
}
Inserting an internal helper before a tunnel
During onChain, use tunnelInsertBefore(target, inserted, chain) to place an
already-created helper between a tunnel and its previous neighbour. Both tunnels
must be distinct and unchained, the target must have a previous neighbour, the
helper must have no neighbours, and the chain must still be mutable. These are
caller preconditions, checked with Debug assertions. An already-pending startup
failure returns false without changing the chain or links.
The helper first registers inserted in the chain. A chain-capacity or aggregate
size failure records a startup failure and returns false without changing the
neighbour links; the caller must return. On success it connects
previous -> inserted -> target. It changes previous->next only when that link
previously pointed to target, preserving a branch owner's separate default
route. Branch callers obtain the callable entry with tunnelGetBranchEntry().
The operation neither registers target nor calls either tunnel's onChain.
The caller still handles optional helpers, deferral until a previous neighbour
exists, configured-next validation, its own insertion and onward traversal.
Check startup failure after insertion before continuing. A nested onChain may
merge and destroy the supplied chain; reacquire it through tunnelGetChain()
before subsequent work, as with ordinary chaining.
The lifecycle-v2 shutdown stages
TlsClient's configured fragment is an example of a private helper after its
owner. Copy the nested settings, configure the child with nodeConfigureChild()
and the owner's next link, then insert TlsClient -> StreamFragmenter during
chaining. The child's normal traversal binds the configured next entry, including
that entry's own helpers. The child participates in indexing, padding, layer
solving and lifecycle traversal; TlsClient retains its splice-blocking metadata.
Destroy the child through tunnelOwnedChildDestroy() before releasing its settings
and node strings, including construction rollback. Do not manually repeat its
chain-driven shutdown hooks or allocate another line for this composition.
Lifecycle ownership is explicit and context-bearing. Process shutdown invokes these stages for every tunnel in every loaded chain. Within a chain, NodeManager uses storage order; that order is not guaranteed to be source-to-tail:
onQuiesceRequestruns on the main thread, closes local admission, issues producer stop requests, and must not wait.onWorkerQuiesceruns on each owner worker and detaches that worker's timers, watchers, and component-specific queued handles.onQuiesceWaitruns on the main thread after every worker is quiesced and joins or detaches already-admitted external callback roots.onWorkerStopruns on each owner worker during drain. Every source or internal normal-line owner closes its authoritative inventory when visited; another hook must not assume a neighbouring owner has already run or require a borrowed registry to be empty.onStopruns on the main thread after every worker is drained and releases remaining shared resources.onDestroyruns on the main thread only after no worker, message, line, or external producer can reference the instance.
Every callback receives a read-only ww_lifecycle_context_t. Construction
rollback, owner-driven private-child teardown, and local device restart use
their own scopes and abortive close policy. Components must not infer a scope by
polling process-global shutdown state. Live configuration removal is not a
supported lifecycle scope.
Use the project's JSON helpers (getStringFromJsonObject,
getBoolFromJsonObjectOrDefault, getIntFromJsonObjectOrDefault, etc.) and
LOGF a clear message on bad config — misconfiguration should fail loudly at
startup, not silently at runtime.
For per-node MTU settings, seed an int64_t from CORE_DEFAULT_MTU, strictly
parse any override with jsonGetObjectIntegerInRange(), and validate the final
value before narrowing or allocating MTU-dependent resources. Missing settings
must not bypass validation: the JSON helper does not validate an inherited seed.
Store the result in immutable tunnel state and use it for all runtime work,
including delayed callbacks and lazy protocol objects. Invalid inherited values
must name core misc.mtu and the supported node override in the diagnostic.
See Core configuration ownership.
structure.h: tstate and lstate
structure.h defines the two state structs and their sizes. Keep settings and
runtime fields clearly grouped, as TlsClient does:
typedef struct tlsclient_tstate_s
{
// settings (parsed once in create.c)
char *sni;
char *alpn;
bool verify;
// runtime, shared across all lines
SSL_CTX **threadlocal_ssl_contexts; // one per worker
} tlsclient_tstate_t;
typedef struct tlsclient_lstate_s
{
SSL *ssl;
BIO *rbio;
BIO *wbio;
buffer_queue_t bq;
bool handshake_completed;
bool resources_released;
} tlsclient_lstate_t;
Per-worker arrays (like threadlocal_ssl_contexts) live in tstate and are
indexed only by an explicitly validated worker identity: lineGetWID(line) in a
line callback, worker->wid in a worker-message callback, or a checked
getCurrentWorkerWID() when the callback contract guarantees an event
worker. Never index them with raw getWID(). Per-connection state lives in
lstate.
For frequently allocated line states and objects with several private flags owned
by the same worker, prefer adjacent bool flag : 1 fields, as in the SOCKS,
Trojan/VLESS line states and HttpProxyServer session/header states.
Group flags to avoid alignment holes. Keep ordinary booleans when callers need
their addresses, including JSON output parameters, and preserve indexed arrays
where useful. Packed flags share a storage unit: they are not independent atomic
objects or a wire format. Compare both sizeof and the rounded line-state size
before claiming an allocation saving; layouts depend on the target compiler/ABI.
Line-state types must require no more than kTunnelLineStateAlignment (32-byte)
alignment. Use tunnelTryAlignLineStateSize() when composing embedded line-state
regions, and include each rounded region in the tunnel's reservation before
chaining. tunnelTryAlignStateSize() is for cache-aligned tunnel-wide state.
Allocation sizes, embedded offsets, and zeroing lengths must use the same
line-state alignment; clearing a larger region can overwrite the next slot.
line_state.c: Initialize and Destroy
Nodes that reserve zero line-state bytes need no placeholder lstate type or unused initialization/destruction helpers. Omit those declarations and source files, and keep any line-state size constant consistent with the constructor.
Line state is initialized in Init and destroyed exactly once. The destroy
function must zero the aligned region so the line-free debug assertion
(debugAssertZeroBuf, see Part 2) is satisfied:
void templateLinestateInitialize(template_lstate_t *ls)
{
// set up this connection's state
}
void templateLinestateDestroy(template_lstate_t *ls)
{
memoryZeroAligned32(ls, tunnelGetCorrectAlignedLineStateSize(sizeof(template_lstate_t)));
}
Rules (from Part 2, restated where you write the code):
- Initialize in
Init; later callbacks assume the state exists. - Initialize the final line-state object before attaching queues to an embedded buffer budget. Its address must remain stable; never copy populated state with self-references. Destroy attached queues and release active reservations, assert zero budget usage, then clear the line-state slot.
- Destroy once; after
LinestateDestroy()consider the state dead and zeroed. - Before permanent line bypass,
call
LinestateDestroy()and zero the entire aligned reservation, including padding.tunnelBypassLine()asserts this in Debug before clearing the bit; the bypassed tunnel will not receiveFinishto perform cleanup later. - Never read a field after destroying the state.
- Do not add an
initializedboolean unless an explicitly documented line-role/callback contract requires state before ordinaryInit.
APIServer and public operation boundaries
APIServer is a Layer-4 terminal node carrying four-byte unsigned big-endian lengths followed by UTF-8 JSON envelopes. It borrows normal lines, blocks splice, and creates no listener. Direct TCP, ordinary HttpServer body tunneling and TcpOverUdpServer's reconstructed stream provide its reliable input. Raw UDP is not an API transport. HTTP headers, method/path routing and per-operation HTTP status mapping do not belong to the API envelope layer.
Authenticate each envelope using APIServer's independent static operator token
before target lookup. Resolve exact logical configuration/node names globally,
then apply the exact operation allowlist. Node-only selection counts every
declared top-level node with that name, including nodes without a registered API
or permission. Never let permission filtering remove ambiguity, expose generated
children or load a filename from a selector. All loaded configuration names must
be nonempty and unique when an APIServer is present; configurations without an
APIServer retain their existing rules. Preparation resolves each permission to an
immutable registered descriptor; runtime admission reuses that descriptor after
registry freeze. Missing exact permission is forbidden. read_only describes an
operation and enforces neither access, rollback nor retry safety.
Keep lexical depth/value limits local to the small outer envelope. Reject unknown/duplicate keys, malformed UTF-8, trailing input and decoded NUL in keys, metadata and UTF-8 body strings. Strict canonical base64 carries arbitrary body bytes. Preserve decoded opaque bytes without parsing or reserializing the node's schema. Do not log credentials, envelopes, bodies or returned user data.
Reserve memory before parsing, copying, encoding and handler dispatch, including
temporary overlaps. Bound input bytes, retained allocation charge, fragment and
request entries, sessions, unfinished jobs and output. Measure the complete
encoded response before emitting its prefix. Node-level failures remain dispatched
results; an encoded result over the limit becomes a bounded gateway error.
max_jobs=64 is an independent ceiling, not guaranteed capacity: at current defaults the shared 64 MiB domain
fits roughly seven small jobs with their 8 MiB handler allowances before substantial session storage. Retained
input/output and allocation geometry may reduce that count; do not shrink handler allowance to promise 64 jobs.
Connections without a verified token also charge a separate synchronized
max_unauthenticated_memory domain, including temporary parsing overlaps and
error output. Its default is max(one line allowance, one quarter of total memory)
and configuration leaves total headroom for one handler job. On successful
credential verification release the entire anonymous charge once; ordinary
line/domain charges remain until their owners settle. Every envelope still
authenticates independently. Clear decoded tokens immediately after verification
or schema refusal, with release-time clearing as a backstop.
One operation executes at a time per connection; queued requests and responses
remain FIFO. As an exact-line tail, APIServer gates its reply producer on consumer
Pause/Resume, while finite deadlines and hard limits remain active. Schedule
bounded parsing/output continuations so buffered work does not require new input.
After a turn reaches its work limit, a complete queued request with no active job
or response is also runnable work and requires a continuation. Waiting for a job
or paused output alone must not cause repeated immediate wakeups; their deadlines
remain armed. The frame deadline starts at first-fragment arrival, including
queue residence. Reuse a published timer with wtimerReset() after refreshing
owner-loop time; preserve the earliest absolute deadline and suppress obsolete
pending delivery. Reset refusal closes through ordinary cleanup without an add
retry. The timer callback clears its slot before progress; quiescence cancels it.
Malformed framing/envelope/schema and invalid authentication close after at most one bounded error.
Gateway timeout and invalid_encoding also close the connection.
Publishing a complete response ends that request's deadline and begins its
independent output deadline. Recheck the original deadline after encoding and before publication; securely discard
an unpublished late response and follow the timeout/closure path. A timeout does not roll back a committed operation.
Once a prefix/body starts, expiry closes the line
instead of injecting a second frame. A queued request expiring behind an earlier
pending response closes without emitting an out-of-order error.
The handler's reserved body allowance is separate from the encoded envelope cap.
Reserve at least 4096 body bytes even for a smaller envelope cap, so a bounded
valid diagnostic result can become response_too_large at serialization. During
preparation, working memory must fit that result's actual worker-pool allocation
plus 8192 bytes of bounded parsing metadata after final padding is known.
AuthenticationServer keeps API-only create/update/password preparation, typed apply and handlers beside their
operations. Their shared precommit ACK formatter runs before publication; typed core commit scratch checks and exact
unknown-outcome semantics remain. Public JSON rendering emits ordinary spans in chunks, checks deadlines at entry,
within every 256 emitted bytes and before successful completion, and retains per-write overflow/allowance checks.
List rendering measures the empty envelope and fitting immutable prefix once, then writes that same measured prefix
without repeating its sizing pass. Credential-bearing producer pages remain separate from redacted administrative views.
Producer password and digest point lookups likewise use the bounded full-row copy while retaining collection ownership.
Check the exact compact JSON size against the response payload allowance minus the frame header before copying arbitrary
strings. Oversized rows return response-too-large; invalid stored encoding returns user-json-serialize-failed.
AuthenticationClient's registered status handler runs on worker 0 and copies
owned scalar values before serialization. Pin active/baseline tables and
preflight their immutable collection counts before allocating a detailed scan.
Preserve control_mutex -> users_lock -> collection/user -> stats lock order;
aggregate sampling describes an interval while traffic continues. Diagnostics
never change admission, observations, baselines, batches or scheduling state.
AuthenticationServer administrative auditing uses a node-local event queue and
checked file writer, separate from diagnostic logging. Emit committed events at
the same state-locked revision boundary as each API/producer mutation, before
reply delivery; report rejection only when that attempt did not commit. Copy
bounded actor/gateway metadata and permitted field-name bits, never arbitrary
body/value/credential strings. Queue admission holds only a short metadata mutex,
never waits for free capacity and performs no allocation, rendering or file I/O.
The independent writer serializes bounded JSONL records, checks append/flush and
rotation, and latches file failure before dropping queued/subsequent records.
Queue saturation or writer failure changes audit health, not account outcomes.
Status uses an independently sampled, redacted audit object even when disabled.
The node reference
defines settings, retention, fields and the intentionally non-durable guarantee.
AuthenticationServer explicitly registers the read-only status, get-user
and list-users operations plus writable update-user, create-user and
change-user-password, each on the calling event
worker. The strict read-operation parser must apply the 4096-byte, depth-two and 16-value bounds
before building a JSON tree. IDs are canonical nonzero unsigned decimal strings;
all numeric result leaves use decimal strings without floating-point conversion.
Public projections copy only their explicit permitted fields and never use the
credential-bearing persistence serializer or a whole-user copy with secrets
removed afterward.
Acquire server state, collection, user configuration and user statistics in that
order, using nonblocking read acquisition throughout. Contention discards partial
copies and returns the node-level busy error. Never report an empty successful
sample on acquisition failure or wait behind an existing save. Hold state through
the bounded owned copy to keep configuration/revisions coherent, then release all
locks before measuring/rendering JSON. No borrowed user pointer or stored string
survives unlock. Status does constant-size work, detail uses one indexed ID
lookup, and a page touches at most its requested 1..100 entries. Bound scans,
temporary overlaps and actual pool allocation against deadlines and allowances;
account result bodies have a further 256 KiB cap. Detail strings remain complete
or produce a bounded refusal. Lists copy at most 256 complete UTF-8 bytes of the
name and never scan email/notes; an invalid inspected name prefix produces an
explicit null preview while traversal continues.
List cursors use the current collection order and are stateless position hints, not credentials or persistent snapshots. Bind each 64-character canonical cursor to the random process-local server instance, configuration revision and next position: 32 lowercase hexadecimal instance characters, 16 revision characters and 16 position characters. Check instance/revision/count under the state read lock before indexing. Any accepted configuration revision change invalidates prior cursors, including same-value patches. Removal/replacement paths must publish their configuration revision under the same state write lock. Statistics-only changes do not invalidate them; each page reports its current statistics revision and accounting-clock reference. Advance only past emitted records, return a smaller nonempty prefix when budgets require it, and never issue a partial-success cursor after timeout or contention.
Every successful server read publishes an owned edit_token sampled with its
configuration revision: the existing 32 lowercase hexadecimal instance characters
followed by 16 revision characters. The 48-character token is a server-wide
optimistic edit precondition, not an authorization credential; pagination cursors
keep their separate 64-character representation. Statistics-only mutations do
not change the token, while any accepted configuration edit does, including
unrelated-account and same-value edits. Recreating the server changes its identity.
update-user requires its own exact operation permission. Keep monitors read-only;
producer credentials and protocol allow-user-write grant no gateway permission.
Use public snake_case and u/d leaves, with canonical unsigned decimal strings,
in this operation only. Require exactly nonzero id, canonical if_match, and a
nonempty sparse patch. Reject protected fields, empty supplied containers,
unknown/duplicate keys, wrong types and invalid encoding before any mutation.
Bound the body to 32 KiB, lexical depth to four object levels and values to 64
before constructing its JSON tree. Preserve read-parser bounds unchanged.
Share the owned configuration-only preparation, semantic validation, preflight
and nonfailing commit primitives with the existing blocking protocol/object
callers. The API tries locks in server state write -> collection write -> target user configuration write order. Configuration patches must not acquire or write
the statistics lock or replace whole user/time/statistics snapshots. Under state,
compare instance and revision before resolving the account; mismatch is
conflict without a fresh token. Ordinary contention returns busy without
waiting. Acquire every required lock before erasing an index entry or changing a
field. Complete uniqueness, overlap, capacity, memory and deadline checks before
the first logical mutation; no refusal may leave half-applied state.
The API name-index path must use reserved/no-grow insertion and non-advancing
erase; it must not reserve count + 1 and rehash the table for one edit. Bound
inspection of the selected account's old name to 32 KiB plus terminator detection
only when renaming, and refuse oversized names without changing them. Other
selected fields must not hash/inspect that name for diagnostics. Preserve existing
average-case hash semantics; cluster probing/shifting is not a count-independent
worst-case latency guarantee. Reuse existing Allowed-IP tree nodes when possible;
preflight exact checked new-growth geometry against 1 MiB and the remaining
working allowance before allocation/copy. No second index or full-user scan is
introduced. Fixed API diagnostics must not scan or log arbitrary stored names,
notes, request bodies or credentials.
Charge parser storage, prepared values, scratch, index growth and the actual result-pool allocation including overlaps. Preflight the fixed 256-byte success body and reserve its buffer before mutation. Render the anticipated ID, next revision and token before the final precommit deadline check. Then commit the selected fields/index membership and increment configuration revision exactly once within the state-write transaction. After commit, do no fallible allocation, deadline-aware rendering, fresh snapshot or rollback: return that transaction's prepared acknowledgement. Private capacity reservation may survive a precommit refusal; logical account/index/revision/accounting state must not change.
Guard configuration-revision exhaustion before mutation in both API and producer AddNewUser/UpdateUser paths, and keep an invariant guard in the increment helper. UINT64_MAX-1 to UINT64_MAX remains valid; further mutations refuse rather than wrap. Preserve statistics, usage, first-use/creation observations, credentials, runtime reservations and producer checkpoints. Disabled/expired accounts remain editable by durable ID. API success acknowledges memory only, with no save or client-refresh guarantee. Existing save frequency and producer ordering remain.
A returned operation-level failure guarantees this edit did not commit. A lost reply, abandoned connection or late gateway timeout may follow a committed edit and therefore leaves its outcome unknown. Preserve generic dispatcher completion behavior; never infer rollback or add cancellation rollback. Callers reread and reconsider after conflict or unknown outcome; never automatically obtain a fresh token and replay the old patch. Request IDs are correlation labels rather than idempotency records. The compact acknowledgement, including maximum request-ID escaping, must fit the gateway's minimum 1024-byte encoded envelope.
create-user has an independent exact operation permission; update permission
and producer credentials do not grant creation. Require a caller-supplied
canonical nonzero decimal-string ID and nonempty exact UTF-8 password, with only
an optional public config. Reuse the 18 strict configuration leaves and numeric
rules without relaxing update-user's nonempty-patch requirement. Omitted or empty
creation config preserves userCreate() defaults: enabled, 120000 ms statistics
interval, empty strings and zero unlimited/unset limits/expiry. Supplied nested
objects must remain nonempty. Bound creation bodies to 32 KiB, depth four and 64
lexical values including keys before JSON-tree allocation. Reject token fields,
protected import fields, aliases, NUL and malformed UTF-8.
Prepare a fresh private owned user outside shared locks; credential derivation
must not run under the API state/collection locks. Preserve 32-byte user_t
alignment with matched aligned allocation/release, and never copy initialized
native locks with struct assignment or memcpy. Derive ordinary credentials from
the exact supplied password and use constructor-generated wall-clock creation
time. Keep statistics, first use, runtime and client-local observations empty.
Destroy/wipe owned password staging and unpublished users on every refusal and
release preparation ownership after transfer. Public creation/read responses
remain projections with no credentials.
The creation transaction tries server state write -> collection write, without
acquiring any existing user's config/statistics lock. Reject exhausted revision
under state; under collection prioritize existing ID over other duplicate
classes. Share existing indexed integrity/preflight/reserved insertion with
blocking add/load callers while preserving their duplicate precedence. Creation
never upserts or treats an identical account as a successful replay. Fixed
credentials_exist groups hash/UUID/WireGuard conflicts without revealing keys
or the conflicting account; crypto/backend or native-init refusal is typed
creation_failed, separate from malformed input.
Before any reserve, admit complete checked new extents for active pointers, monotonic stable slots, block pointers/new 64-user blocks, all seven hash maps' value/metadata arrays and optional Allowed-IP growth. Use real STC rounding and load-factor geometry. Charge private preparation, aligned allocation overhead, scratch, unpublished-slot/string copies and actual result-pool storage, including peak overlaps, against the supplied aggregate working allowance. Creation has no fixed 1 MiB total-growth cap. Keep name-map capacity sufficient for all active users, including unnamed accounts, so later bounded renames can insert without growth. Removed stable slots remain unavailable until collection clear.
When name-map growth is required, inspect admitted buckets and stored names with a cumulative byte-work budget equal to the supplied working allowance, including terminator detection and bounded deadline checks. Refuse before rehash if the scan cannot fit. Skip this full scan when name capacity is sufficient. Preserve average-case collision semantics and finite synchronous native rehash/crypto; check deadlines between reserves without promising interruptible operations or a strict response-time bound. Fixed diagnostics never scan/log stored names or credentials.
Reserve/prebuild the same 256-byte transaction ACK and actual pool charge before publication. Prepare an unpublished stable slot, including all native lock/string failure paths, and check deadlines immediately before publication. Reserved insertion must not allocate/rehash: publish all indexes, active pointer, active count and monotonic slot count once, then increment configuration revision once under state ownership. Impossible commit-stage inconsistencies are fatal invariants, not ordinary partial-success errors. Private reserved capacity may survive precommit refusal; membership/counts/revisions and published pointers must remain stable. After commit return the existing ACK without allocation, rendering, deadline refusal or rollback. Existing accounts, statistics revision and producer checkpoints remain unchanged.
Creation has no edit-token precondition: unrelated valid creations/edits do not
invalidate the input. Success returns the exact committed revision/token and
invalidates older tokens/cursors. No immediate save or telemetry update occurs;
ordinary periodic/final persistence and client refresh remain separate. Keep
producer AddNewUser synchronous save/rollback unchanged. An offline client
cannot admit a previously unknown account until normal refresh, and existing
pending accounting must survive that refresh.
A returned creation node failure publishes no account. Post-dispatch delivery
failure is outcome unknown even if shutdown/disconnect abandons the reply. The
caller retains the original ID/inputs and rereads that ID. Presence establishes
existence, not creator or password ownership; absence permits an explicit same-ID
retry decision while the first request may still run. Never automatically choose
another ID, replace a user, reset credentials or treat id_exists as replay
proof. IDs are caller-managed permanent identities unique in the current
collection, without an all-time ledger or cross-crash exactly-once guarantee.
change-user-password requires its own exact permission; a password administrator
normally receives only get-user and this operation. Require exactly canonical
nonzero decimal-string id, canonical if_match and a nonempty exact UTF-8
password. Reject aliases, extra/duplicate fields, NUL, malformed encoding and
nested values. Before JSON-tree allocation, bound the body to 32 KiB, root-only
container depth one and 16 lexical values including keys. Check token mismatch
under state before resolving the account. Disabled, expired and quota-exhausted
accounts remain editable; a same-password assignment still advances revision once.
Share ordinary credential derivation and the lock-held credential transfer with the object layer. Prepare one owned password and fixed derived hashes/UUID/key material outside shared locks, deriving each primitive once. Preserve required alignment and paired allocation/release; never copy user objects or native locks. Charge parser/prepared/scratch/result storage and peak overlaps against the working allowance. Wipe owned request/decoded/prepared secrets on every result and securely clear replaced plaintext and temporary secret-equivalent material. Fixed diagnostics must not include bodies, credentials or arbitrary stored strings.
Try locks in server state write -> collection write -> target user configuration write order, with no statistics lock, I/O or worker wait. Check the four credential
indexes for another account's keys, allowing the target's own identities. Plain to
UUID, UUID to plain, UUID replacement and case-equivalent UUID assignments must
update validity/membership correctly. A textual change need not revoke an unchanged
protocol identity. Admit the selected old password using its authoritative cached owned length under the existing
locks, with a 32 KiB maximum; an oversized imported password yields resource_limit unchanged. Keep bounded scanning
of external new passwords, deadline checks before/after precommit, and secure cleanup proportional to the replaced
allocation.
Inspect no unrelated strings or full table. Preserve average-case hash semantics,
without promising interruptible crypto or worst-case constant-time collision work.
The object implementation keeps storage/import/copy in users.c, hash indexes in users_index.c, Allowed-IP intervals
in users_allowed_ip.c, transactions/preparations in users_update.c and users_prepared.c, public projections in
users_public.c, and runtime accounting/migration in users_runtime.c. Private locked helpers/types stay in
users_internal.h. The four fixed-byte credential maps share a typed private template; name, ID, pointer and interval
indexes retain their distinct semantics. User state, credential preparation and copy staging share an aligned owned
credential value; initialized native locks are always constructed separately.
Every AuthenticationServer account must fit a complete synchronization page, including future emitted numeric/statistics growth. The full-row serializer derives a fixed reserve using all five string keys and maximum scalar widths, including unsigned values encoded as decimal strings. Cached escaped-string charges supply the remaining row cost. Startup derives the one-row page reserve from maximum-width page metadata and subtracts that plus the nine-byte response frame header from the response payload limit; the outer envelope has separate geometry. The objects library receives only the resulting row-byte allowance.
Prepared create/configuration/password transactions check this prospective bound
under their existing ownership locks before precommit or logical mutation.
Configuration checks overlay selected prepared charges in constant work over
five fields, without reading unchanged text or acquiring a statistics lock.
Producer patches use the blocking export-limited transaction; administrative
transactions retain their try-lock/deadline behavior. Generic convenience APIs
explicitly delegate with SIZE_MAX; zero is a real zero-byte allowance. Typed
too-large refusals preserve all account/index/accounting state. Keep the read-time
exact bounded serializer as an independent defensive check.
User expiry helper now_ms belongs to the projected client's monotonic domain
when a projection is valid, and otherwise to persisted expiry's wall-time
domain. Accounting-clock installation and observation rebuild local expiry from
retained first-use evidence; the manual projection setter does not erase that
evidence. Runtime migration targets unpublished staging users, preserving new
policy/timeinfo while transferring old reservations and accounting clocks, then
rebuilding each projection before publication. See
accounting ownership.
Missing old credential ownership, impossible index entry counts and missing reserved insertion capacity are internal corruption: emit a fixed fatal diagnostic and terminate directly. Ordinary duplicate and capacity refusals remain typed precommit results. Corrupt removal must never allocate or rebuild indexes before terminating.
All credential maps reserve for every active user, including users without UUIDs. Replacement admits final occupancy within existing capacity and uses non-advancing erase plus reserved/no-grow insertion, without reserve/rehash. Acquire all locks and finish uniqueness, capacity, budget and deadline checks before any logical mutation. Prebuild the existing 256-byte ACK with this transaction's next revision/token in already admitted pool storage. After a final deadline check, transfer credentials and publish all memberships as a nonfailing commit, then advance configuration revision once under state. Do not allocate, render, take a new snapshot or return a deadline refusal after commit. Refusals preserve all logical account/index state; irreversible invariant failures are fatal rather than recoverable partial changes.
Preserve user address/ID, count/order/slots, configuration/Allowed-IP indexes,
creation/expiry/first-use, traffic/statistics revision, checkpoints and reservations.
Use typed credentials_exist and password_change_failed results for collisions
and backend failures. Guard revision exhaustion before mutation. API success is
memory-only; periodic/final saving and normal client refresh remain separate.
The ACK schema reuses the existing minimum-envelope proof. A returned node failure
means this invocation did not commit; a lost reply leaves the outcome unknown.
Reread for a current token, but do not treat public fields or an advanced revision
as proof of the password. An explicit resubmission of the retained desired password
can establish it at a new commit point. Never fetch a token and retry automatically,
or add request records, replay-success shortcuts or crash recovery.
Primary-save telemetry is fixed-size process-local state under the server lock. Record an existing locked save once, at its primary failure/commit decision; loading and read queries do not create attempts. Use monotonic ages with explicit validity bits so zero is valid, preserve the previous success after a failure, and do not downgrade primary success for optional postcommit backup/sync warnings. This view changes neither persistence frequency nor its commit guarantee.
AuthenticationServer verbose and producer allow-stats-push, allow-user-pull and allow-user-write must be
unique JSON Booleans in their own objects; absence defaults to false. Reject invalid types before runtime timers.
AuthenticationServer database loading and producer AddNewUser must validate the
complete byte span before importing users: reject trailing non-whitespace, invalid
UTF-8, raw/decoded NUL and duplicate decoded keys. Database reads retain their
actual length and existing cJSON nesting policy; do not substitute API-envelope
byte/value caps or change supported import layouts. Use
configLexicalParseBoundedQuietWithCleanup() to wipe owned strings before the
strict validator deletes a fully parsed but rejected tree; accepted trees remain
the caller's responsibility. jsonWipeStrings() follows cJSON ownership and leaves
reference values/children and constant keys alone. Partial trees discarded inside
cJSON on syntax failure and unowned transport copies are outside this cleanup.
AuthenticationServer saving distinguishes a primary absent at open from other
read failures. Confirmed absence publishes the valid in-memory table without
rotating or requiring the existing recovery backup; subsequent saves resume
ordinary rotation. Empty/read/close failures retain the ordinary save refusal.
A failed producer addition may reclaim only its unpublished last storage slot through
usersRollbackUnpublishedLastUserByIdentifier(). The caller must continuously hold external exclusive transaction
ownership from insertion to rollback, expose no candidate pointer and roll back the most recent addition. The helper
locks the collection, validates both last-active and last-allocated identity, removes all indexes and destroys the
candidate before decrementing the slot count. Identity/storage mismatches are fatal invariants. Retain allocated
capacity for retries; ordinary removal still invalidates pointers and never reuses their slots.
Startup rollback must skip final database saving while releasing all created
resources; normal process shutdown saves a loaded database. Weekly history slots
subtract calendar days from tm_mday before local mktime, with tm_isdst=-1.
Audit path checks preserve bounded configured/resolved paths and checked suffix
formatting, and distinguish unsupported paths, failed parent resolution and actual
canonical/file-identity collisions. Every failure refuses startup before writer
creation.
Shared user Allowed-IP parsing requires complete inet_pton IPv4/IPv6 syntax;
trim only surrounding whitespace, then preserve existing prefix checks, host-bit
masking and overlap rules. IPv4-mapped IPv6 remains IPv6: ::ffff:10.0.0.1/128 is distinct from
10.0.0.1/32 for lookup and overlap; never silently convert it to IPv4. Imports reserve no more than 64 speculative records
above the existing count before validating entries. A string-valued password
or pass identifies a standalone record; object-valued entries with those names
remain name-map users. The selected password still takes precedence over pass,
and the users wrapper retains precedence. Failed append restores prior records,
handles and indexes without requiring capacity to shrink.
See APIServer for gateway settings, the API protocol for wire envelopes, and the transport examples for HTTP/1.1 composition.
HTTP-Specific Rules
TlsClient registers ALPS only for an offered h2, with empty client application
settings and codepoint 17613. BoringSSL serializes the ClientHello protocol
list; 02 68 32 is the length-prefixed h2 name, never the settings value passed
to SSL_add_application_settings(). HTTP/1.1 and custom ALPN offers have no ALPS
registration. Received server settings remain inside TLS: TlsClient neither
applies them to an HTTP engine nor forwards them to adjacent nodes. This shapes
the TLS exchange without promising Chrome's application-layer behavior. See the
TlsClient ALPS reference.
HttpProxyClient is a separate HTTP/1.1 node using HttpProxyCommon's pure parser.
It has one borrowed normal line and no authentication service dependency.
CONNECT optionally owns an internal DomainResolver tunnel before the client core:
source -> origin resolver -> HttpProxyClient -> optional TlsClient -> proxy transport.
The helper uses the same borrowed line and participates in chain indexing,
padding, layer solving and splice capability. Branch callers enter through
tunnelGetBranchEntry(). The parent destroys its helper through owned-child
destruction; normal chain traversal already drives its shutdown hooks.
The following transport independently selects/resolves the proxy; core Init snapshots
the configured/dynamic origin for CONNECT authority or an absolute HTTP target.
Its HTTP mode generates one request, permits only successful final responses,
and delivers decoded body bytes. It does not share HttpClient's HTTP/2,
WebSocket or split-connection machinery. Client callbacks keep request startup,
upload framing, response decoding and directional cleanup in their corresponding
files. Common helpers own strict settings, headers, bounded ordering drains and
timer cleanup. Capability is advertised only for the documented direct body paths
and checked parsing fallback; whole-chain splice gates remain authoritative.
HttpProxyServer exposes an HTTP/1.x forward-proxy interface, unlike the body
transport interfaces of HttpClient and HttpServer. Its incoming normal line
is borrowed; each outbound normal child is created and destroyed by the proxy.
The incoming line stops at the proxy and only the child initializes the actual
next chain entry. Child responses and pressure cross back to the associated
client identity. Authentication selects exactly one of no-auth, an immutable local credential
list, or AuthenticationClient tracked accounts. Each authenticated request
reauthenticates before reuse. Reuse requires the requested authority and mode;
local mode also requires the same complete credential pair, while tracked mode
requires credential bytes and user ID/generation.
Resolved destination context is mutable and must not be used as that authority
key. Only tracked mode creates the internal UserController and adds a real user
handle with credentials to the child. Local mode adds a credentials-only marker
for routing, with no new account tracking or per-user limits. Both preserve
inherited identities and avoid appending repeated markers to the borrowed
client or reused child. Current TCP read EOF closes the full transport,
including on IOCP; proxy persistence does not introduce half-close semantics.
CONNECT and fallback retain this same split between a borrowed client line and an owned child. HttpProxyServer remains the tail of one line and the head of the other, so it keeps mapped producer pressure and cannot permanently bypass itself. Opaque payload relay does not remove its association, deadline or child-cleanup responsibilities.
HttpProxyServer keeps authentication commitment, request reuse and branch selection
in upstream/payload.c, with credential evaluation in common/auth.c returning
explicit acceptance, denial, service-unavailability or internal-failure outcomes.
Strict settings and local-user parsing live in common/settings.c; constructor
allocation, callback wiring and rollback remain in instance/create.c. Response
processing stays in downstream/payload.c. Both HTTP nodes put implementation
helpers in internal.h, state in structure.h, and exports/custom callbacks in
interface.h. Init, Est, Finish and receiver permission updates live in their
corresponding callback files. common/input.c
shares bounded admission and HTTP framing; common/flow.c coordinates exchange
progress, raw relay and combined producer pressure; common/lifecycle.c owns
session references and child creation/teardown. The shared pump serializes reentry
and advances retained work from Payload, Resume, Est and timer callbacks.
The session embeds two hps_direction_state_t objects in directions, indexed
by kHpsUpstream (client to selected child entry) and kHpsDownstream (child to
client). Each groups its ordinary input/output/deferred/active buffers, body
parser, optional trailer-owner pointer, header deadline, receive nesting count,
and permission flags. Only an effective chunked body allocates a trailer owner,
which holds the parsed context, saved raw header and its length together. Parsed
trailer names borrow that saved header. Transfer-Encoding metadata on HEAD/304
responses does not allocate an owner. paused records consumer permission;
read_paused records pressure sent to the producer. Thus upstream Pause/Resume changes downstream paused,
and downstream Pause/Resume changes upstream paused.
Directions share the session's allocation and lifetime; an interior direction
pointer grants no independent lifetime or permission after a callback. Never copy
or reset a complete live direction object. Trailer cleanup detaches the owner
pointer before wiping and freeing the header and owner, leaving the header deadline
independent. Completion, canceled upload on an early final response, errors and
terminal cleanup settle the owner. Acquisition happens before outward callbacks
and follows the fatal heap-allocation policy. Exchange and terminal
cleanup preserve the existing member-specific resets, including receive counters
that unwind after reentrant close. Child close
resets only upstream paused and downstream read_paused within the directions.
Association, authentication, timer, pump and asymmetric exchange state remain in
the session. Upstream producer pressure still considers total session retention;
downstream response progress must remain possible while request storage is full.
Optional fallback-node-name selects a distinct configured L4 branch only for
first-request authentication denial or service unavailability. Explicit lookup
outcomes distinguish rejection from internal errors; parser, local-storage and
post-authentication failures never select fallback. Successful authentication
commits a monotonic connection latch before rewriting, local OPTIONS or outward
callbacks. Returning to request parsing must not reset it. Fallback creates only
one owned child through the resolved callable entry, bypasses this proxy's private
UserController, inherits legitimate metadata, and never installs rejected
credentials or the failed authority as child routing metadata. Its exact original
wire replay includes credential bytes, followed by permanent opaque bidirectional
relay. No local HTTP response or branch retry is allowed after selection. Connect
and idle deadlines remain; HTTP header deadlines end at handoff. The node uses the existing worker child inventory and quiescence gates.
Splice relay requires no older directional storage, no active pump/Init or outer
Payload dispatch, and a live exact association. CONNECT also requires its success
handoff; fallback does not require Est. Delivery uses the selected entry under
the pump guard, so nested input materializes and waits in FIFO order. Handoff
consumes the wrapper even if a callback closes the session. All retained input,
output, deferred and active incoming buffers remain ordinary; HTTP bodies stay
materialized throughout an exchange.
After authentication selects a normal HTTP exchange, HttpProxyServer requests
ordinary upstream reads on the borrowed client line and ordinary downstream
reads on each owned HTTP child before its Init. CONNECT and fallback do not
request these preferences. The two directions belong to different lines, so the
server uses the directional helpers separately. An incoming preference from an
earlier HTTP exchange remains set if that connection later enters CONNECT.
These requests do not replace handling for splice input already delivered or
arriving through another line association.
All HTTP activity and deadline timestamps use wloopNowMonotonicMS() on the
client line's owner loop, with cached-loop freshness and no wall-clock adjustment.
Pure authority, header syntax and streaming body parsing live in
tunnels/Internals/HttpProxyCommon. HttpProxyServer retains its request rewriting
and Basic credential decoding in its own common/parser.c; its parser entry
preserves HTTP/1.0 admission. The common HTTP/1.1 response entry distinguishes
ordinary, HEAD and CONNECT contexts. Successful CONNECT validates generic header
syntax but ignores Content-Length and Transfer-Encoding body framing, including
conflicting fields; ordinary responses retain strict framing validation.
HttpProxyServer uses the shared ASCII/hex helpers and wwBase64DecodeCanonical()
from ww for Basic credentials. The canonical decoder bounds output capacity,
requires standard Base64 padding where needed, rejects nonzero padding bits and
writes no NUL terminator. The proxy reserves that terminator itself and retains
its own credential rules: no control bytes, the first colon as separator, and
1–255 bytes each for username and password. wwBase64Decode() does not enforce
canonical padding bits and must not replace the canonical decoder for this path.
HttpProxyServer treats max-pending-bytes (P) as its working pending-data
budget. Four working buffers retain at most 4 * P allocation bytes. Each
direction may additionally hold one already-delivered remainder of at most
D = 2 MiB logical bytes. This node-owned headroom is independent of pool sizes,
RAM profile and splice settings. Total retained logical bytes are bounded by
P + 2 * D. A single delivery is limited to P + D; only its immediately drainable prefix may exceed the
per-direction remainder allowance. Each remainder's
payload capacity is at most max(small_capacity, 2 * D) plus cache-line rounding;
its full padding, sbuf header, and aligned-allocation overhead are additional.
Remainder admission copies into ordinary best-fit storage (pooled or dedicated)
when needed to satisfy this bound. Working slice room excludes deferred
bytes so moving bytes into working storage never double-counts them. Remainders
participate in pressure, FIFO pumping, and every error/timeout/close cleanup path.
Blocked pipelined requests leave response progress available.
Raw fallback and CONNECT relay do not copy their deferred bytes back into working
storage to forward them. One paused direction must not consume the capacity
required to drain the other. Their owned child's Finish settles that child
immediately and drains already accepted replies under current client permission
before finishing the borrowed client.
HalfDuplexServer and HttpServer split derive waiting budgets from
scale = max(1, ceil(basis / 32768)), where
basis = bufferpoolGetWaitingBudgetBasis() is 64 KiB in S1/S2 and 1 MiB in higher
profiles, independently of allocation sizes and the splice limit.
HalfDuplexServer closes at or above 131070 * scale, including initial upload
publication; HttpServer split closes above 131070 * scale unless its explicit
buffering-limit bypass is enabled. Handshake/header validation and available-peer
pairing precede waiting checks. The 32 KiB divisor is a fixed budget reference. Wire and HTTP framing limits remain independent.
ReverseServer uses fixed per-half application FIFO limits of 2 MiB and 1,024 buffers, shared by waiting and startup retention and independent of pool profile. Its activation prefix has a separate protocol bound; see Part 3.
If you work on HttpClient or HttpServer, these source-backed constraints
apply:
- HTTP/1.x header limits count bytes through the first complete
CRLF CRLF, including the terminator. Body bytes received in the same buffer do not count against that limit. Without a terminator, the buffered header prefix remains subject to the limit. This applies to request, response, and split headers. - WaterWall targets HTTP/1.x and single-stream HTTP/2 only. Do not add HTTP/3. Do not support more than one logical HTTP/2 stream. If a peer opens extra streams, reject or ignore them conservatively per the existing design.
HttpClientandHttpServershare the ordinary-buffer HTTP/2 DATA-provider lifecycle intunnels/Internals/HttpCommon. Each queue stays at a stable address on its buffer pool's owner worker. Append transfers payload ownership; read EOF does not release the active item. Only its matching DATA frame-send callback retires it, or terminal cleanup releases it after deleting the nghttp2 session.DATA_EXISTretains the same front item; another submission error releases only the rejected item and leaves the rest for terminal cleanup. Nodes retain stream selection, HEADERS readiness, empty-input policy, END_STREAM decisions and wire output. Keep both line-survival and state/direction checks after wire callbacks.- HttpClient framing: headers remain separate from body input. HTTP/1 body chunks contain at most 16,372 payload bytes and publish prefix, body and final CRLF in one buffer. Empty input is not a terminal chunk. Reentrant body input must remain behind all pieces of older input. Nested admission uses the existing queue with limits of eight buffers, 1 MiB payload and 2 MiB allocation charge; refusal closes the line. Finish stops output and releases every untransferred piece. HTTP/2 DATA must wait until nghttp2 has serialized this request's HEADERS, including when a callback admits payload during startup.
- HttpClient HTTP/2 profile: direct startup combines preface, initial SETTINGS and the connection WINDOW_UPDATE before separate request HEADERS. The ordinary SETTINGS profile is Chrome 154's 64 KiB header table, disabled push, 6 MiB stream window and 256 KiB header-list limit; the connection window is 15 MiB and the default 16 KiB receive-frame limit remains. WebSocket additionally advertises extended CONNECT support. Share this SETTINGS definition with h2c upgrade. Outgoing DATA payloads are capped at 16,375 bytes so the nine-byte frame header fits within one 16 KiB TLS plaintext record. Do not confuse advertised receive limits with outbound DATA sizing or infer browser pooling from this profile.
- h2c upgrade (
nghttp2_session_upgrade2()): stream1is the original upgraded request. The client must not submit a second synthetic request on stream1; the server must not auto-generate a fake synthetic response on stream1. - h2c with a request body: upgrade-with-body on the original HTTP/1.1 request is not safely supported in the single-stream design. The client should reject it; the server should ignore or reject an upgrade whose HTTP/1.1 request carries a body.
- Clean finish: an upstream
Finishfor the request side may require final HTTP bytes first. Send them while holding a line reference, then destroy local state, then forward the realFinish— the general final-bytes pattern from Part 2. Do not keep HTTP line state alive after a clean finish unless you intentionally held a line reference for that purpose. - HttpClient split teardown: retain the borrowed main line and both owned
transport lines before final-byte or close callbacks. A callback on one
transport can close its sibling and clear main state while the borrowed main
line remains alive. After callbacks, check logical life and the existing main
split role before cleaning or notifying it again. Honor the main line's
prev_finishedflag even when closure arrives through the other transport. - Custom raw upgrade: a successful non-WebSocket, non-h2c HTTP/1.1 upgrade makes the single-line client and server transparent after the opening header, coalesced input and retained output settle. Serialize reentrant input behind older bytes during this transition. Then destroy and zero the aligned line state before permanent bypass; suspended startup/parser callbacks must not access retired state. HTTP body framing, HTTP/2, WebSocket and split modes stay active for their ongoing protocol or line-ownership duties. Pause/Resume already relay transparently on ordinary same-line paths; split paths retain their exact line mapping and Finish guards.
- Pairing IDs (
HttpClientsplit mode andHalfDuplexClient): give every logical pair a fresh 128-bit identifier from two sequencedfastRand64()values, then copy that same identifier to both child transport lines. Never derive pairing IDs from counters or non-cryptographic output. The ID is internal routing metadata, not authentication or transport protection; chains that need those properties use dedicated security nodes. - HalfDuplex rendezvous publication:
HalfDuplexServerperforms the opposite-role lookup and, when no peer exists, publication of the current role as one lock-linearized transaction across both pending maps. This keeps simultaneous upload and download arrivals from both observing a miss. A peer owned by another WID is identified while the maps are locked, but the current decision copies only that WID, not the borrowed peer line-state pointer, and the current line is piped only after releasing the lock. No worker handoff or tunnel callback runs inside the pending-map transaction. Final child destroy requires both borrowed pending maps to be empty and never reclaims their line state or buffered payload on behalf of the real line owners.
The Implementation Workflow
Use this order for any new feature or fix. Inspect the repository first; ask a focused question only when a consequential requirement, compatibility choice, or scope boundary cannot be resolved from code and documentation. Make and record reasonable assumptions for low-risk implementation details instead of delegating them back to the maintainer.
- Draw the chain flow. Mark the Up-end and Down-end, and label which callback direction owns the transformation.
- Identify the line owner. Which tunnel creates and destroys the line?
- Read the existing tunnel and its immediate neighbors.
- Read the contracts:
ww/net/line.h,ww/net/tunnel.h, andww/net/chain.c. - If framing/prepending is involved, read
ww/bufio/shiftbuffer.handbuffer_pool.h, plus nearby tunnels'node.cpadding. - Compare relevant mature tunnel patterns, but verify them against the contract, callers, and neighbors rather than copying them blindly. Check existing containers and helpers before adding a private data structure, buffer utility, or timer-based idle scheduler.
- Implement the smallest complete root-cause fix that preserves composition. Do not minimize the diff by retaining a known defect, duplicating unsafe logic, or patching only a symptom.
- Decide whether focused regression coverage is warranted. It normally is for new behavior, a reproducible regression-prone bug, a meaningful edge case, or a contract change—especially when a test can fail on reversed direction. Reuse existing direct coverage when sufficient, and do not manufacture behavior tests for mechanical or documentation-only work.
- Validate with the preset build and the relevant tests (see Part 6).
A new generic layer is rarely the fix here. Preserve the established lifecycle contract and prefer a direct complete fix. If one tunnel appears to require a new lifecycle concept, stop and verify the need against the core contract and every affected caller; do not use a reference tunnel as proof that either the old or new shape is correct.
Continue to Part 6: Building, Testing & Reviewing.
Attaching a tunnel to lwIP
initTcpIpStack() is an idempotent construction-time shared-bootstrap facade;
it creates no thread. Global startup initializes shared storage before spawning
workers. The runtime lazily constructs an engine on its ordinary owner worker.
The standalone engine constructor retains its nullable contract: a failed
construction leaves the slot empty and the affected packet/flow refused.
A tunnel constructor must not create foreign engines or publish partial netifs.
PTC and CTP attach separate netifs to the owner's shared engine. Their
onWorkerStop hooks detach local PCBs, listeners, drains, timers and netifs.
Main-thread onStop checks completed worker barriers and frees shared node
services; it must not traverse worker stack objects. Nodes never destroy engines.
Runtime teardown releases each engine after detached messages settle and before
the saved event loop and owner pools are destroyed.
TunDevice and PTC advertise trusted packet-checksum capability only in their node templates. The active bit starts clear and is published by TunDevice after final adjacency and device framing are known. Constructors and PTC startup must not cache an inactive decision before that publication. Public node/tunnel layouts remain unchanged; mode never propagates through a helper node.
TunDevice also advertises the static kNodeFlagPacketPayloadEnqueueOnly flag.
Its packet payload callback must preserve Part 2's non-reentry and finite enqueue
contract across Linux, Darwin and Windows. No public node/tunnel layout changes
are required. PTC tests exact reciprocal adjacency, L3 sink capability and owner
packet-line identity; it never infers this promise from type names or checksum
trust. Removing the promise requires returning that edge to deferred dispatch.